From e3d3f4bb5b193f586eece7c13d71fea6ebd66ecb Mon Sep 17 00:00:00 2001 From: Willem Wyndham Date: Tue, 19 May 2026 13:46:40 -0400 Subject: [PATCH 01/16] feat: tansu-DAO-gated registry manager contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New `registry-tansu-manager` contract that wraps a Tansu workspace as the registry's manager. `execute(proposal_id)` fetches the proposal, verifies it is `Approved` in the configured `project_key`, and forwards its single `OutcomeContract` to the registry via XCC — satisfying the registry's `manager.require_auth()` through contract-auth chaining. Co-Authored-By: Claude Opus 4.7 (1M context) --- contracts/registry-tansu-manager/Cargo.toml | 23 ++ contracts/registry-tansu-manager/src/lib.rs | 153 +++++++++ contracts/registry-tansu-manager/src/test.rs | 322 +++++++++++++++++++ 3 files changed, 498 insertions(+) create mode 100644 contracts/registry-tansu-manager/Cargo.toml create mode 100644 contracts/registry-tansu-manager/src/lib.rs create mode 100644 contracts/registry-tansu-manager/src/test.rs diff --git a/contracts/registry-tansu-manager/Cargo.toml b/contracts/registry-tansu-manager/Cargo.toml new file mode 100644 index 0000000..8d00807 --- /dev/null +++ b/contracts/registry-tansu-manager/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "registry-tansu-manager" +description = "Tansu-DAO-gated manager for the stellar registry." +version = "0.1.0" +license = "Apache-2.0" +edition = "2021" +publish = false +repository = "https://github.com/theahaco/scaffold-stellar/tree/main/contracts/registry-tansu-manager" + +[lib] +crate-type = ["cdylib"] +doctest = false + +[dependencies] +soroban-sdk = { workspace = true } +soroban-sdk-tools = "0.1.2" + +[dev-dependencies] +soroban-sdk = { workspace = true, features = ["testutils"] } +soroban-sdk-tools = { version = "0.1.2", features = ["testutils"] } + +[package.metadata.stellar] +cargo_inherit = true diff --git a/contracts/registry-tansu-manager/src/lib.rs b/contracts/registry-tansu-manager/src/lib.rs new file mode 100644 index 0000000..f658813 --- /dev/null +++ b/contracts/registry-tansu-manager/src/lib.rs @@ -0,0 +1,153 @@ +#![no_std] + +use soroban_sdk::{ + contract, contractimpl, contracttype, vec, Address, Bytes, BytesN, Env, IntoVal, String, + Symbol, Val, Vec, +}; + +#[soroban_sdk_tools::scerr] +pub enum Error { + NotApproved, + NoOutcomeContracts, + MultipleOutcomes, + OutcomeTargetMismatch, +} + +// Layout MUST match Consulting-Manao/tansu contracts/tansu/src/types.rs. + +#[contracttype] +#[derive(Clone)] +pub enum VoteChoice { + Approve, + Reject, + Abstain, +} + +#[contracttype] +#[derive(Clone)] +pub struct PublicVote { + pub address: Address, + pub weight: u32, + pub vote_choice: VoteChoice, +} + +#[contracttype] +#[derive(Clone)] +pub struct AnonymousVote { + pub address: Address, + pub weight: u32, + pub encrypted_seeds: Vec, + pub encrypted_votes: Vec, + pub commitments: Vec>, +} + +#[contracttype] +#[derive(Clone)] +#[allow(clippy::large_enum_variant)] +pub enum Vote { + PublicVote(PublicVote), + AnonymousVote(AnonymousVote), +} + +#[contracttype] +#[derive(Clone)] +pub struct VoteData { + pub voting_ends_at: u64, + pub public_voting: bool, + pub token_contract: Option
, + pub votes: Vec, +} + +#[contracttype] +#[derive(Clone)] +pub enum ProposalStatus { + Active, + Approved, + Rejected, + Cancelled, + Malicious, +} + +#[contracttype] +#[derive(Clone)] +pub struct OutcomeContract { + pub address: Address, + pub execute_fn: Symbol, + pub args: Vec, +} + +#[contracttype] +#[derive(Clone)] +pub struct Proposal { + pub id: u32, + pub title: String, + pub proposer: Address, + pub ipfs: String, + pub vote_data: VoteData, + pub status: ProposalStatus, + pub outcome_contracts: Option>, +} + +#[contracttype] +pub enum Cfg { + Tansu, + ProjectKey, + Registry, +} + +#[contract] +pub struct RegistryTansuManager; + +#[contractimpl] +impl RegistryTansuManager { + pub fn __constructor(env: Env, tansu: Address, project_key: Bytes, registry: Address) { + let s = env.storage().instance(); + s.set(&Cfg::Tansu, &tansu); + s.set(&Cfg::ProjectKey, &project_key); + s.set(&Cfg::Registry, ®istry); + } + + pub fn tansu(env: Env) -> Address { + env.storage().instance().get(&Cfg::Tansu).unwrap() + } + + pub fn project_key(env: Env) -> Bytes { + env.storage().instance().get(&Cfg::ProjectKey).unwrap() + } + + pub fn registry(env: Env) -> Address { + env.storage().instance().get(&Cfg::Registry).unwrap() + } + + pub fn execute(env: Env, proposal_id: u32) -> Result { + let s = env.storage().instance(); + let tansu: Address = s.get(&Cfg::Tansu).unwrap(); + let project_key: Bytes = s.get(&Cfg::ProjectKey).unwrap(); + let registry: Address = s.get(&Cfg::Registry).unwrap(); + + let proposal: Proposal = env.invoke_contract( + &tansu, + &Symbol::new(&env, "get_proposal"), + vec![&env, project_key.into_val(&env), proposal_id.into_val(&env)], + ); + + if !matches!(proposal.status, ProposalStatus::Approved) { + return Err(Error::NotApproved); + } + let outcomes = proposal + .outcome_contracts + .ok_or(Error::NoOutcomeContracts)?; + if outcomes.len() != 1 { + return Err(Error::MultipleOutcomes); + } + let oc = outcomes.get(0).unwrap(); + if oc.address != registry { + return Err(Error::OutcomeTargetMismatch); + } + + Ok(env.invoke_contract(®istry, &oc.execute_fn, oc.args)) + } +} + +#[cfg(test)] +mod test; diff --git a/contracts/registry-tansu-manager/src/test.rs b/contracts/registry-tansu-manager/src/test.rs new file mode 100644 index 0000000..0428aeb --- /dev/null +++ b/contracts/registry-tansu-manager/src/test.rs @@ -0,0 +1,322 @@ +extern crate std; + +use soroban_sdk::{ + contract, contractimpl, contracttype, symbol_short, testutils::Address as _, vec, Address, + Bytes, Env, IntoVal, String, Symbol, Vec, +}; +use soroban_sdk_tools::auth::setup_mock_auth; + +use crate::{ + Error, OutcomeContract, Proposal, ProposalStatus, RegistryTansuManager, + RegistryTansuManagerClient, VoteData, +}; + +// Stub Tansu contract: stores one Proposal under (project_key, id) and returns it on get_proposal. + +#[contracttype] +enum TansuStubKey { + Proposal(Bytes, u32), +} + +#[contract] +pub struct TansuStub; + +#[contractimpl] +impl TansuStub { + pub fn set_proposal(env: Env, project_key: Bytes, proposal: Proposal) { + env.storage() + .instance() + .set(&TansuStubKey::Proposal(project_key, proposal.id), &proposal); + } + + pub fn get_proposal(env: Env, project_key: Bytes, proposal_id: u32) -> Proposal { + env.storage() + .instance() + .get(&TansuStubKey::Proposal(project_key, proposal_id)) + .unwrap() + } +} + +// Stub registry: requires manager auth on `manager_only`, records the value. + +#[contracttype] +enum RegStubKey { + Manager, + Recorded, +} + +#[contract] +pub struct RegistryStub; + +#[contractimpl] +impl RegistryStub { + pub fn __constructor(env: Env, manager: Address) { + env.storage().instance().set(&RegStubKey::Manager, &manager); + } + + pub fn manager_only(env: Env, value: u32) -> u32 { + let manager: Address = env.storage().instance().get(&RegStubKey::Manager).unwrap(); + manager.require_auth(); + env.storage().instance().set(&RegStubKey::Recorded, &value); + value + } + + pub fn recorded(env: Env) -> Option { + env.storage().instance().get(&RegStubKey::Recorded) + } +} + +// --------------------------------------------------------------------------- +// Test scaffolding +// --------------------------------------------------------------------------- + +struct Setup { + env: Env, + project_key: Bytes, + tansu: Address, + registry: Address, + #[allow(dead_code)] + manager: Address, + manager_client: RegistryTansuManagerClient<'static>, +} + +fn setup() -> Setup { + let env = Env::default(); + let project_key = Bytes::from_slice(&env, &[7u8; 16]); + let tansu = env.register(TansuStub, ()); + + // Pre-compute the manager address so the registry can be constructed with it + // as `manager`. (Registers a transient placeholder, then registers the real + // manager contract at a deterministic address derived from arg hash — simpler: + // register the manager first, then the registry with the manager address.) + let manager = env.register( + RegistryTansuManager, + ( + tansu.clone(), + project_key.clone(), + // dummy registry address; rewritten via instance storage below + Address::generate(&env), + ), + ); + let registry = env.register(RegistryStub, (manager.clone(),)); + + // Patch the manager's stored registry to the real RegistryStub address. + env.as_contract(&manager, || { + env.storage() + .instance() + .set(&crate::Cfg::Registry, ®istry); + }); + + let manager_client = RegistryTansuManagerClient::new(&env, &manager); + + Setup { + env, + project_key, + tansu, + registry, + manager, + manager_client, + } +} + +fn empty_vote_data(env: &Env) -> VoteData { + VoteData { + voting_ends_at: 0, + public_voting: true, + token_contract: None, + votes: Vec::new(env), + } +} + +fn plant_proposal( + env: &Env, + tansu: &Address, + project_key: &Bytes, + id: u32, + status: ProposalStatus, + outcomes: Option>, +) { + let proposal = Proposal { + id, + title: String::from_str(env, "t"), + proposer: Address::generate(env), + ipfs: String::from_str(env, ""), + vote_data: empty_vote_data(env), + status, + outcome_contracts: outcomes, + }; + let client = TansuStubClient::new(env, tansu); + client.set_proposal(project_key, &proposal); +} + +fn one_outcome(env: &Env, registry: &Address, value: u32) -> Vec { + vec![ + env, + OutcomeContract { + address: registry.clone(), + execute_fn: symbol_short!("man_only"), + args: vec![env, value.into_val(env)], + }, + ] +} + +// --------------------------------------------------------------------------- +// Happy path: approved proposal -> registry call succeeds via contract auth +// --------------------------------------------------------------------------- + +#[test] +fn approved_proposal_forwards_to_registry() { + let s = setup(); + let outcomes = vec![ + &s.env, + OutcomeContract { + address: s.registry.clone(), + execute_fn: Symbol::new(&s.env, "manager_only"), + args: vec![&s.env, 42u32.into_val(&s.env)], + }, + ]; + plant_proposal( + &s.env, + &s.tansu, + &s.project_key, + 1, + ProposalStatus::Approved, + Some(outcomes), + ); + + // No external signer needed: the manager contract's auth satisfies + // registry's `manager.require_auth()` via the XCC contract-auth chain. + let result: u32 = s + .manager_client + .execute(&1) + .try_into() + .expect("Val should decode to u32"); + + assert_eq!(result, 42); + + let reg = RegistryStubClient::new(&s.env, &s.registry); + assert_eq!(reg.recorded(), Some(42)); +} + +// --------------------------------------------------------------------------- +// Negative cases +// --------------------------------------------------------------------------- + +#[test] +fn active_proposal_is_rejected() { + let s = setup(); + plant_proposal( + &s.env, + &s.tansu, + &s.project_key, + 1, + ProposalStatus::Active, + Some(one_outcome(&s.env, &s.registry, 1)), + ); + + let err = s.manager_client.try_execute(&1).err().unwrap().unwrap(); + assert_eq!(err, Error::NotApproved); +} + +#[test] +fn rejected_proposal_is_rejected() { + let s = setup(); + plant_proposal( + &s.env, + &s.tansu, + &s.project_key, + 1, + ProposalStatus::Rejected, + Some(one_outcome(&s.env, &s.registry, 1)), + ); + + let err = s.manager_client.try_execute(&1).err().unwrap().unwrap(); + assert_eq!(err, Error::NotApproved); +} + +#[test] +fn proposal_without_outcomes_is_rejected() { + let s = setup(); + plant_proposal( + &s.env, + &s.tansu, + &s.project_key, + 1, + ProposalStatus::Approved, + None, + ); + + let err = s.manager_client.try_execute(&1).err().unwrap().unwrap(); + assert_eq!(err, Error::NoOutcomeContracts); +} + +#[test] +fn proposal_with_multiple_outcomes_is_rejected() { + let s = setup(); + let outcomes = vec![ + &s.env, + OutcomeContract { + address: s.registry.clone(), + execute_fn: Symbol::new(&s.env, "manager_only"), + args: vec![&s.env, 1u32.into_val(&s.env)], + }, + OutcomeContract { + address: s.registry.clone(), + execute_fn: Symbol::new(&s.env, "manager_only"), + args: vec![&s.env, 2u32.into_val(&s.env)], + }, + ]; + plant_proposal( + &s.env, + &s.tansu, + &s.project_key, + 1, + ProposalStatus::Approved, + Some(outcomes), + ); + + let err = s.manager_client.try_execute(&1).err().unwrap().unwrap(); + assert_eq!(err, Error::MultipleOutcomes); +} + +#[test] +fn proposal_targeting_wrong_address_is_rejected() { + let s = setup(); + let wrong = Address::generate(&s.env); + let outcomes = vec![ + &s.env, + OutcomeContract { + address: wrong, + execute_fn: Symbol::new(&s.env, "manager_only"), + args: vec![&s.env, 1u32.into_val(&s.env)], + }, + ]; + plant_proposal( + &s.env, + &s.tansu, + &s.project_key, + 1, + ProposalStatus::Approved, + Some(outcomes), + ); + + let err = s.manager_client.try_execute(&1).err().unwrap().unwrap(); + assert_eq!(err, Error::OutcomeTargetMismatch); +} + +// --------------------------------------------------------------------------- +// Auth-flow guard: the registry's manager-only function must reject calls +// that come from somewhere other than the manager contract. +// --------------------------------------------------------------------------- + +#[test] +#[should_panic] // require_auth on the manager address fails for an outside caller +fn registry_rejects_direct_caller() { + let s = setup(); + let outsider = Address::generate(&s.env); + + // Authorize the outsider (not the manager contract) and call directly. + setup_mock_auth(&s.env, &s.registry, "manager_only", (99u32,), &[&outsider]); + let reg = RegistryStubClient::new(&s.env, &s.registry); + reg.manager_only(&99u32); +} From 87b44ff38c507d23f253332db46c75dbb207a467 Mon Sep 17 00:00:00 2001 From: Willem Wyndham Date: Tue, 19 May 2026 13:52:10 -0400 Subject: [PATCH 02/16] feat(registry-tansu-manager): replay protection + review polish Address review feedback before opening PR: - Add replay protection: successful `execute` records `DataKey::Executed(id)` in persistent storage; later calls return `AlreadyExecuted`. Adds a test that re-running the same proposal fails. - Add an explicit test for outcomes targeting the manager itself (already blocked by `OutcomeTargetMismatch`; the test makes the intention durable against future refactors). - Doc comments on every `Error` variant. - Doc comment on `execute` covering the auth model (why no explicit `authorize_as_current_contract` is needed) and the project_key trust assumption. - Rename `Cfg` -> `DataKey` to match the convention used by the test stubs and the wider Soroban ecosystem. - Note on the Tansu-types comment that field order must stay in lock step with upstream `Consulting-Manao/tansu`. Co-Authored-By: Claude Opus 4.7 (1M context) --- contracts/registry-tansu-manager/src/lib.rs | 59 +++++++++++++++----- contracts/registry-tansu-manager/src/test.rs | 54 +++++++++++++++++- 2 files changed, 99 insertions(+), 14 deletions(-) diff --git a/contracts/registry-tansu-manager/src/lib.rs b/contracts/registry-tansu-manager/src/lib.rs index f658813..84b5e85 100644 --- a/contracts/registry-tansu-manager/src/lib.rs +++ b/contracts/registry-tansu-manager/src/lib.rs @@ -7,13 +7,22 @@ use soroban_sdk::{ #[soroban_sdk_tools::scerr] pub enum Error { + /// Proposal exists but is not in the `Approved` state. NotApproved, + /// Proposal has no outcome contracts attached. NoOutcomeContracts, + /// Proposal has more than one outcome contract. MultipleOutcomes, + /// Proposal's outcome targets an address other than the configured registry. OutcomeTargetMismatch, + /// Proposal has already been executed by this manager. + AlreadyExecuted, } -// Layout MUST match Consulting-Manao/tansu contracts/tansu/src/types.rs. +// Tansu types mirrored from Consulting-Manao/tansu `contracts/tansu/src/types.rs`. +// Field order, variant order, and `#[contracttype]` annotations must stay +// identical to the upstream — Soroban encodes structs/enums by position, so +// silent drift would surface as decode failures at runtime. #[contracttype] #[derive(Clone)] @@ -89,10 +98,11 @@ pub struct Proposal { } #[contracttype] -pub enum Cfg { +pub enum DataKey { Tansu, ProjectKey, Registry, + Executed(u32), } #[contract] @@ -102,28 +112,49 @@ pub struct RegistryTansuManager; impl RegistryTansuManager { pub fn __constructor(env: Env, tansu: Address, project_key: Bytes, registry: Address) { let s = env.storage().instance(); - s.set(&Cfg::Tansu, &tansu); - s.set(&Cfg::ProjectKey, &project_key); - s.set(&Cfg::Registry, ®istry); + s.set(&DataKey::Tansu, &tansu); + s.set(&DataKey::ProjectKey, &project_key); + s.set(&DataKey::Registry, ®istry); } pub fn tansu(env: Env) -> Address { - env.storage().instance().get(&Cfg::Tansu).unwrap() + env.storage().instance().get(&DataKey::Tansu).unwrap() } pub fn project_key(env: Env) -> Bytes { - env.storage().instance().get(&Cfg::ProjectKey).unwrap() + env.storage().instance().get(&DataKey::ProjectKey).unwrap() } pub fn registry(env: Env) -> Address { - env.storage().instance().get(&Cfg::Registry).unwrap() + env.storage().instance().get(&DataKey::Registry).unwrap() } + /// Execute a passed Tansu proposal by forwarding its outcome to the registry. + /// + /// The proposal must be in `Approved` state and carry exactly one + /// `OutcomeContract` whose `address` matches the configured registry. The + /// outcome's `execute_fn` + `args` are forwarded via XCC — the registry's + /// `manager.require_auth()` is satisfied automatically because this + /// contract is the direct caller (Soroban contract-auth chains for + /// outgoing invocations; no `authorize_as_current_contract` is needed). + /// + /// Replay-protected: a successful `execute` records `DataKey::Executed(id)` + /// permanently; later calls with the same `proposal_id` return + /// `AlreadyExecuted`. + /// + /// Trust: we look up the proposal in Tansu using the stored `project_key`, + /// so a wrong-project proposal cannot resolve. We do not re-verify + /// `project_key` against any field of the returned proposal — Tansu's + /// storage layout makes that lookup the only path. pub fn execute(env: Env, proposal_id: u32) -> Result { - let s = env.storage().instance(); - let tansu: Address = s.get(&Cfg::Tansu).unwrap(); - let project_key: Bytes = s.get(&Cfg::ProjectKey).unwrap(); - let registry: Address = s.get(&Cfg::Registry).unwrap(); + let s = env.storage().persistent(); + if s.has(&DataKey::Executed(proposal_id)) { + return Err(Error::AlreadyExecuted); + } + let inst = env.storage().instance(); + let tansu: Address = inst.get(&DataKey::Tansu).unwrap(); + let project_key: Bytes = inst.get(&DataKey::ProjectKey).unwrap(); + let registry: Address = inst.get(&DataKey::Registry).unwrap(); let proposal: Proposal = env.invoke_contract( &tansu, @@ -145,7 +176,9 @@ impl RegistryTansuManager { return Err(Error::OutcomeTargetMismatch); } - Ok(env.invoke_contract(®istry, &oc.execute_fn, oc.args)) + let result: Val = env.invoke_contract(®istry, &oc.execute_fn, oc.args); + s.set(&DataKey::Executed(proposal_id), &true); + Ok(result) } } diff --git a/contracts/registry-tansu-manager/src/test.rs b/contracts/registry-tansu-manager/src/test.rs index 0428aeb..6930d5c 100644 --- a/contracts/registry-tansu-manager/src/test.rs +++ b/contracts/registry-tansu-manager/src/test.rs @@ -104,7 +104,7 @@ fn setup() -> Setup { env.as_contract(&manager, || { env.storage() .instance() - .set(&crate::Cfg::Registry, ®istry); + .set(&crate::DataKey::Registry, ®istry); }); let manager_client = RegistryTansuManagerClient::new(&env, &manager); @@ -309,6 +309,58 @@ fn proposal_targeting_wrong_address_is_rejected() { // that come from somewhere other than the manager contract. // --------------------------------------------------------------------------- +#[test] +fn approved_proposal_cannot_be_replayed() { + let s = setup(); + let outcomes = vec![ + &s.env, + OutcomeContract { + address: s.registry.clone(), + execute_fn: Symbol::new(&s.env, "manager_only"), + args: vec![&s.env, 7u32.into_val(&s.env)], + }, + ]; + plant_proposal( + &s.env, + &s.tansu, + &s.project_key, + 1, + ProposalStatus::Approved, + Some(outcomes), + ); + + s.manager_client.execute(&1); + let err = s.manager_client.try_execute(&1).err().unwrap().unwrap(); + assert_eq!(err, Error::AlreadyExecuted); +} + +#[test] +fn proposal_targeting_manager_itself_is_rejected() { + // An attacker-crafted proposal whose outcome address is the manager + // contract (not the registry) must be rejected — otherwise the manager + // could be tricked into recursively re-entering itself. + let s = setup(); + let outcomes = vec![ + &s.env, + OutcomeContract { + address: s.manager.clone(), + execute_fn: Symbol::new(&s.env, "execute"), + args: vec![&s.env, 1u32.into_val(&s.env)], + }, + ]; + plant_proposal( + &s.env, + &s.tansu, + &s.project_key, + 1, + ProposalStatus::Approved, + Some(outcomes), + ); + + let err = s.manager_client.try_execute(&1).err().unwrap().unwrap(); + assert_eq!(err, Error::OutcomeTargetMismatch); +} + #[test] #[should_panic] // require_auth on the manager address fails for an outside caller fn registry_rejects_direct_caller() { From 051efddbc931a780a49d854d07eb8f82efdff323 Mon Sep 17 00:00:00 2001 From: Willem Wyndham Date: Tue, 19 May 2026 14:14:41 -0400 Subject: [PATCH 03/16] refactor(registry-tansu-manager): use soroban-sdk-tools typed storage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the hand-rolled `DataKey` enum + raw `env.storage().instance()` calls with `#[contractstorage(auto_shorten = true)]` from `soroban-sdk-tools`. The macro generates static one-liner accessors (`Storage::get_tansu`, `Storage::set_executed`, `Storage::has_executed`, …) keyed by auto-shortened symbols, eliminating the `DataKey` enum and the persistent/instance bucket plumbing in `execute`. Behavior, ABI, and tests are unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) --- contracts/registry-tansu-manager/src/lib.rs | 46 ++++++++++---------- contracts/registry-tansu-manager/src/test.rs | 4 +- 2 files changed, 25 insertions(+), 25 deletions(-) diff --git a/contracts/registry-tansu-manager/src/lib.rs b/contracts/registry-tansu-manager/src/lib.rs index 84b5e85..beb5e9a 100644 --- a/contracts/registry-tansu-manager/src/lib.rs +++ b/contracts/registry-tansu-manager/src/lib.rs @@ -4,6 +4,7 @@ use soroban_sdk::{ contract, contractimpl, contracttype, vec, Address, Bytes, BytesN, Env, IntoVal, String, Symbol, Val, Vec, }; +use soroban_sdk_tools::{contractstorage, InstanceItem, PersistentMap}; #[soroban_sdk_tools::scerr] pub enum Error { @@ -97,12 +98,16 @@ pub struct Proposal { pub outcome_contracts: Option>, } -#[contracttype] -pub enum DataKey { - Tansu, - ProjectKey, - Registry, - Executed(u32), +#[contractstorage(auto_shorten = true)] +pub struct Storage { + /// Tansu DAO contract this manager queries proposals from. + tansu: InstanceItem
, + /// Tansu workspace key this manager represents. + project_key: InstanceItem, + /// Registry contract this manager forwards approved outcomes to. + registry: InstanceItem
, + /// Proposal IDs that have already been executed (replay guard). + executed: PersistentMap, } #[contract] @@ -111,22 +116,21 @@ pub struct RegistryTansuManager; #[contractimpl] impl RegistryTansuManager { pub fn __constructor(env: Env, tansu: Address, project_key: Bytes, registry: Address) { - let s = env.storage().instance(); - s.set(&DataKey::Tansu, &tansu); - s.set(&DataKey::ProjectKey, &project_key); - s.set(&DataKey::Registry, ®istry); + Storage::set_tansu(&env, &tansu); + Storage::set_project_key(&env, &project_key); + Storage::set_registry(&env, ®istry); } pub fn tansu(env: Env) -> Address { - env.storage().instance().get(&DataKey::Tansu).unwrap() + Storage::get_tansu(&env).unwrap() } pub fn project_key(env: Env) -> Bytes { - env.storage().instance().get(&DataKey::ProjectKey).unwrap() + Storage::get_project_key(&env).unwrap() } pub fn registry(env: Env) -> Address { - env.storage().instance().get(&DataKey::Registry).unwrap() + Storage::get_registry(&env).unwrap() } /// Execute a passed Tansu proposal by forwarding its outcome to the registry. @@ -138,8 +142,8 @@ impl RegistryTansuManager { /// contract is the direct caller (Soroban contract-auth chains for /// outgoing invocations; no `authorize_as_current_contract` is needed). /// - /// Replay-protected: a successful `execute` records `DataKey::Executed(id)` - /// permanently; later calls with the same `proposal_id` return + /// Replay-protected: a successful `execute` marks the proposal as + /// executed; later calls with the same `proposal_id` return /// `AlreadyExecuted`. /// /// Trust: we look up the proposal in Tansu using the stored `project_key`, @@ -147,14 +151,12 @@ impl RegistryTansuManager { /// `project_key` against any field of the returned proposal — Tansu's /// storage layout makes that lookup the only path. pub fn execute(env: Env, proposal_id: u32) -> Result { - let s = env.storage().persistent(); - if s.has(&DataKey::Executed(proposal_id)) { + if Storage::has_executed(&env, &proposal_id) { return Err(Error::AlreadyExecuted); } - let inst = env.storage().instance(); - let tansu: Address = inst.get(&DataKey::Tansu).unwrap(); - let project_key: Bytes = inst.get(&DataKey::ProjectKey).unwrap(); - let registry: Address = inst.get(&DataKey::Registry).unwrap(); + let tansu = Storage::get_tansu(&env).unwrap(); + let project_key = Storage::get_project_key(&env).unwrap(); + let registry = Storage::get_registry(&env).unwrap(); let proposal: Proposal = env.invoke_contract( &tansu, @@ -177,7 +179,7 @@ impl RegistryTansuManager { } let result: Val = env.invoke_contract(®istry, &oc.execute_fn, oc.args); - s.set(&DataKey::Executed(proposal_id), &true); + Storage::set_executed(&env, &proposal_id, &true); Ok(result) } } diff --git a/contracts/registry-tansu-manager/src/test.rs b/contracts/registry-tansu-manager/src/test.rs index 6930d5c..7d7ab69 100644 --- a/contracts/registry-tansu-manager/src/test.rs +++ b/contracts/registry-tansu-manager/src/test.rs @@ -102,9 +102,7 @@ fn setup() -> Setup { // Patch the manager's stored registry to the real RegistryStub address. env.as_contract(&manager, || { - env.storage() - .instance() - .set(&crate::DataKey::Registry, ®istry); + crate::Storage::set_registry(&env, ®istry); }); let manager_client = RegistryTansuManagerClient::new(&env, &manager); From 6ee3a5afb187e06ca866ffe0ac7871043e4b70f4 Mon Sep 17 00:00:00 2001 From: Willem Wyndham Date: Tue, 19 May 2026 14:49:46 -0400 Subject: [PATCH 04/16] feat: hello example + tansu-stub + testnet e2e script Adds the pieces needed to exercise the registry-tansu-manager flow against a real network: - `contracts/hello/`: minimal hello-world contract (admin in constructor, `hello(to) -> to`) used as the published+deployed-via-DAO payload. - `contracts/test/tansu-stub/`: stand-in for Tansu's `get_proposal`, plus `set_deploy_proposal` and `set_proposal_outcome` helpers so a script can plant an `Approved` proposal directly. Avoids Tansu's collateral/membership/24h voting cycle for E2E. Proposal types are duplicated rather than path-dep'd to keep manager exports out of the stub's wasm. - `contracts/registry-tansu-manager/e2e-testnet.sh`: nine-step bash script that on testnet (or any configured stellar network) deploys a fresh registry, publishes hello, deploys stub + manager, installs the manager, plants a deploy-proposal, executes via the manager, invokes the deployed hello, and verifies replay rejection. Verified green against testnet. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../registry-tansu-manager/e2e-testnet.sh | 195 ++++++++++++++++++ contracts/test/tansu-stub/Cargo.toml | 16 ++ contracts/test/tansu-stub/src/lib.rs | 188 +++++++++++++++++ 3 files changed, 399 insertions(+) create mode 100755 contracts/registry-tansu-manager/e2e-testnet.sh create mode 100644 contracts/test/tansu-stub/Cargo.toml create mode 100644 contracts/test/tansu-stub/src/lib.rs diff --git a/contracts/registry-tansu-manager/e2e-testnet.sh b/contracts/registry-tansu-manager/e2e-testnet.sh new file mode 100755 index 0000000..c067f12 --- /dev/null +++ b/contracts/registry-tansu-manager/e2e-testnet.sh @@ -0,0 +1,195 @@ +#!/usr/bin/env bash +# End-to-end test of the registry-tansu-manager flow against testnet +# (or any configured stellar network via $NETWORK). +# +# Flow: +# 1. Deploy a fresh registry (no manager yet → author can self-publish). +# 2. Author publishes hello.wasm to the registry. +# 3. Deploy a tansu-stub (stand-in for the Tansu DAO). +# 4. Deploy the registry-tansu-manager, pointing at the stub + registry. +# 5. Admin installs the manager on the registry. +# 6. Plant an `Approved` deploy-proposal on the stub. +# 7. Call manager.execute(proposal_id) — registry deploys hello via XCC. +# 8. Verify: invoke hello on the freshly deployed contract. +# 9. Replay guard: second execute(proposal_id) returns AlreadyExecuted. +# +# Usage: contracts/registry-tansu-manager/e2e-testnet.sh +# Env vars: +# NETWORK Stellar network alias (default: testnet; must be `stellar network add`-ed). +# RUN_ID Suffix appended to ephemeral identities/aliases (default: epoch). +# PROPOSAL_ID Proposal id to use (default: 1). +# HELLO_VERSION Version published for hello (default: 0.1.0). +# CONTRACT_NAME Name used when the registry deploys hello (default: hello-$RUN_ID). + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" +WASM_DIR="$REPO_ROOT/target/stellar/local" + +NETWORK="${NETWORK:-testnet}" +RUN_ID="${RUN_ID:-$(date +%s)}" +PROPOSAL_ID="${PROPOSAL_ID:-1}" +HELLO_VERSION="${HELLO_VERSION:-0.1.0}" +CONTRACT_NAME="${CONTRACT_NAME:-hello-${RUN_ID}}" +# 32-byte arbitrary project_key, hex-encoded. Tansu uses keccak256(name); we +# just need a stable 32-byte value the manager can store and the stub can key on. +PROJECT_KEY="aabbccddeeff00112233445566778899aabbccddeeff00112233445566778899" + +HELLO_WASM="$WASM_DIR/hello.wasm" +REGISTRY_WASM="$WASM_DIR/registry.wasm" +MANAGER_WASM="$WASM_DIR/registry_tansu_manager.wasm" +STUB_WASM="$WASM_DIR/tansu_stub.wasm" + +for w in "$HELLO_WASM" "$REGISTRY_WASM" "$MANAGER_WASM" "$STUB_WASM"; do + if [ ! -f "$w" ]; then + echo "❌ missing $w — run \`just build\` first" >&2 + exit 1 + fi +done + +# Ensure the network alias exists locally. testnet is added if missing; any +# other name must be pre-configured by the user. +if ! stellar network ls 2>/dev/null | grep -qx "$NETWORK"; then + if [ "$NETWORK" = "testnet" ]; then + stellar network add testnet \ + --rpc-url https://soroban-testnet.stellar.org \ + --network-passphrase "Test SDF Network ; September 2015" + else + echo "❌ stellar network '$NETWORK' is not configured; run \`stellar network add\` first" >&2 + exit 1 + fi +fi + +ADMIN_ID="${ADMIN_ID:-e2e-admin-${RUN_ID}}" +AUTHOR_ID="${AUTHOR_ID:-e2e-author-${RUN_ID}}" +CALLER_ID="${CALLER_ID:-e2e-caller-${RUN_ID}}" + +ensure_account() { + local id="$1" + if ! stellar keys ls 2>/dev/null | grep -qx "$id"; then + echo "==> Generating + funding $id on $NETWORK" + stellar keys generate --network "$NETWORK" --fund "$id" >/dev/null + fi +} +ensure_account "$ADMIN_ID" +ensure_account "$AUTHOR_ID" +ensure_account "$CALLER_ID" + +ADMIN_ADDR=$(stellar keys address "$ADMIN_ID") +AUTHOR_ADDR=$(stellar keys address "$AUTHOR_ID") + +echo "==> Network: $NETWORK" +echo "==> Run id: $RUN_ID" +echo "==> Admin: $ADMIN_ID ($ADMIN_ADDR)" +echo "==> Author: $AUTHOR_ID ($AUTHOR_ADDR)" + +# 1. Registry — root registry requires a manager at construction; bootstrap +# with admin as the initial manager, then swap to the real manager contract +# in step 5. +echo "==> Deploying registry" +REGISTRY_ID=$(stellar contract deploy --wasm "$REGISTRY_WASM" \ + --source "$ADMIN_ID" --network "$NETWORK" \ + --alias "registry-e2e-${RUN_ID}" \ + -- --admin "$ADMIN_ADDR" --manager "\"$ADMIN_ADDR\"") +echo " registry: $REGISTRY_ID" + +# 2. Upload hello's wasm and have admin-as-manager publish it on the author's +# behalf. With a manager set, the registry requires manager auth for the +# first publish under a given wasm name; the recorded author is still +# $AUTHOR_ADDR. +echo "==> Uploading hello.wasm" +HELLO_HASH=$(stellar contract upload --wasm "$HELLO_WASM" \ + --source "$ADMIN_ID" --network "$NETWORK") +echo " hash: $HELLO_HASH" + +echo "==> Publishing hello@$HELLO_VERSION (author=$AUTHOR_ADDR, manager=$ADMIN_ID)" +stellar contract invoke --id "$REGISTRY_ID" \ + --source "$ADMIN_ID" --network "$NETWORK" \ + -- publish_hash \ + --wasm_name hello \ + --author "$AUTHOR_ADDR" \ + --wasm_hash "$HELLO_HASH" \ + --version "$HELLO_VERSION" + +# 3. Tansu stub. +echo "==> Deploying tansu-stub" +TANSU_ID=$(stellar contract deploy --wasm "$STUB_WASM" \ + --source "$ADMIN_ID" --network "$NETWORK" \ + --alias "tansu-stub-${RUN_ID}") +echo " stub: $TANSU_ID" + +# 4. Manager pointing at the stub + registry. +echo "==> Deploying registry-tansu-manager" +MANAGER_ID=$(stellar contract deploy --wasm "$MANAGER_WASM" \ + --source "$ADMIN_ID" --network "$NETWORK" \ + --alias "manager-e2e-${RUN_ID}" \ + -- \ + --tansu "$TANSU_ID" \ + --project_key "$PROJECT_KEY" \ + --registry "$REGISTRY_ID") +echo " manager: $MANAGER_ID" + +# 5. Install the manager on the registry. +echo "==> Installing manager on registry" +stellar contract invoke --id "$REGISTRY_ID" \ + --source "$ADMIN_ID" --network "$NETWORK" \ + -- set_manager --new_manager "$MANAGER_ID" + +# 6. Plant an Approved deploy-proposal on the stub. +echo "==> Planting Approved deploy-proposal #$PROPOSAL_ID for contract '$CONTRACT_NAME'" +stellar contract invoke --id "$TANSU_ID" \ + --source "$ADMIN_ID" --network "$NETWORK" \ + -- set_deploy_proposal \ + --project_key "$PROJECT_KEY" \ + --proposal_id "$PROPOSAL_ID" \ + --registry "$REGISTRY_ID" \ + --wasm_name "hello" \ + --version "\"$HELLO_VERSION\"" \ + --contract_name "$CONTRACT_NAME" \ + --admin "$ADMIN_ADDR" + +# 7. Execute the proposal via the manager. No external signer is required — +# the registry's manager.require_auth() is satisfied by the manager +# contract's own outgoing-call auth. +echo "==> Executing proposal via manager" +stellar contract invoke --id "$MANAGER_ID" \ + --source "$CALLER_ID" --network "$NETWORK" \ + -- execute --proposal_id "$PROPOSAL_ID" + +# 8. Verify the registry now resolves the deployed contract. +echo "==> Resolving deployed contract via registry" +DEPLOYED_RAW=$(stellar contract invoke --id "$REGISTRY_ID" \ + --source "$CALLER_ID" --network "$NETWORK" \ + -- fetch_contract_id --contract_name "$CONTRACT_NAME") +DEPLOYED="${DEPLOYED_RAW//\"/}" +echo " deployed: $DEPLOYED" + +echo "==> Calling hello on the deployed contract" +GREETING=$(stellar contract invoke --id "$DEPLOYED" \ + --source "$CALLER_ID" --network "$NETWORK" \ + -- hello --to world) +echo " hello(world) = $GREETING" + +# 9. Replay guard. +echo "==> Re-executing proposal — must fail with AlreadyExecuted" +REPLAY_OUT=$(stellar contract invoke --id "$MANAGER_ID" \ + --source "$CALLER_ID" --network "$NETWORK" \ + -- execute --proposal_id "$PROPOSAL_ID" 2>&1 || true) +if grep -qE 'AlreadyExecuted|Error\(Contract, ?#5\)' <<<"$REPLAY_OUT"; then + echo " ✓ replay rejected" +else + echo " ❌ replay was NOT rejected" >&2 + echo "----- replay attempt output -----" >&2 + echo "$REPLAY_OUT" >&2 + exit 1 +fi + +cat <, + pub encrypted_votes: Vec, + pub commitments: Vec>, +} + +#[contracttype] +#[derive(Clone)] +#[allow(clippy::large_enum_variant)] +pub enum Vote { + PublicVote(PublicVote), + AnonymousVote(AnonymousVote), +} + +#[contracttype] +#[derive(Clone)] +pub struct VoteData { + pub voting_ends_at: u64, + pub public_voting: bool, + pub token_contract: Option
, + pub votes: Vec, +} + +#[contracttype] +#[derive(Clone)] +pub enum ProposalStatus { + Active, + Approved, + Rejected, + Cancelled, + Malicious, +} + +#[contracttype] +#[derive(Clone)] +pub struct OutcomeContract { + pub address: Address, + pub execute_fn: Symbol, + pub args: Vec, +} + +#[contracttype] +#[derive(Clone)] +pub struct Proposal { + pub id: u32, + pub title: String, + pub proposer: Address, + pub ipfs: String, + pub vote_data: VoteData, + pub status: ProposalStatus, + pub outcome_contracts: Option>, +} + +#[contracttype] +enum Key { + Proposal(Bytes, u32), +} + +#[contract] +pub struct TansuStub; + +#[contractimpl] +impl TansuStub { + /// Tansu's real `get_proposal(project_key, proposal_id) -> Proposal`. + /// The stub stores proposals planted via `set_*_proposal` helpers. + pub fn get_proposal(env: Env, project_key: Bytes, proposal_id: u32) -> Proposal { + env.storage() + .persistent() + .get(&Key::Proposal(project_key, proposal_id)) + .unwrap() + } + + /// Plant an `Approved` proposal whose single outcome is + /// `registry.deploy(wasm_name, version, contract_name, admin, init, deployer)`. + /// + /// `admin` is reused as the sole constructor arg, matching the + /// `__constructor(admin: Address)` shape of the `hello` example contract. + /// Use [`set_proposal_outcome`] for any other shape. + pub fn set_deploy_proposal( + env: Env, + project_key: Bytes, + proposal_id: u32, + registry: Address, + wasm_name: String, + version: Option, + contract_name: String, + admin: Address, + deployer: Option
, + ) { + let init: Option> = Some(vec![&env, admin.clone().into_val(&env)]); + let args: Vec = vec![ + &env, + wasm_name.into_val(&env), + version.into_val(&env), + contract_name.into_val(&env), + admin.into_val(&env), + init.into_val(&env), + deployer.into_val(&env), + ]; + Self::store( + &env, + project_key, + proposal_id, + registry, + Symbol::new(&env, "deploy"), + args, + ); + } + + /// Plant a fully custom `Approved` proposal — caller supplies the outcome + /// `(target, fn_name, args)` directly. + pub fn set_proposal_outcome( + env: Env, + project_key: Bytes, + proposal_id: u32, + target: Address, + fn_name: Symbol, + args: Vec, + ) { + Self::store(&env, project_key, proposal_id, target, fn_name, args); + } + + fn store( + env: &Env, + project_key: Bytes, + proposal_id: u32, + target: Address, + fn_name: Symbol, + args: Vec, + ) { + let outcome = OutcomeContract { + address: target, + execute_fn: fn_name, + args, + }; + let proposal = Proposal { + id: proposal_id, + title: String::from_str(env, ""), + proposer: env.current_contract_address(), + ipfs: String::from_str(env, ""), + vote_data: VoteData { + voting_ends_at: 0, + public_voting: true, + token_contract: None, + votes: Vec::::new(env), + }, + status: ProposalStatus::Approved, + outcome_contracts: Some(vec![env, outcome]), + }; + env.storage() + .persistent() + .set(&Key::Proposal(project_key.clone(), proposal_id), &proposal); + } +} From 79ccd7fb4db17ca640cf15871325bb0d2e4d7089 Mon Sep 17 00:00:00 2001 From: Willem Wyndham Date: Tue, 19 May 2026 15:03:40 -0400 Subject: [PATCH 05/16] fix(clippy): satisfy pedantic ruleset (needless_pass_by_value) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI runs `just clippy` which adds `-Dclippy::pedantic` without allowing `needless_pass_by_value`, unlike the workspace .cargo/config.toml. - Switch entry-point signatures (`__constructor`, accessors, `execute`, and the hello contract's methods) to take `&Env` / `&Address` / `&Bytes`, matching the existing pattern in `contracts/registry` and `contracts/test/hello_world`. - Add `#![allow(clippy::needless_pass_by_value, clippy::should_panic_without_expect)]` to `registry-tansu-manager/src/test.rs` (stub contracts in the test module deliberately keep wide value signatures). - Same allow on `contracts/test/tansu-stub/src/lib.rs` — the stub mirrors Tansu's owned-value calling convention and isn't worth the noise of reworking each helper. Co-Authored-By: Claude Opus 4.7 (1M context) --- contracts/registry-tansu-manager/src/lib.rs | 36 ++++++++++---------- contracts/registry-tansu-manager/src/test.rs | 2 ++ contracts/test/tansu-stub/src/lib.rs | 30 ++++++++-------- 3 files changed, 35 insertions(+), 33 deletions(-) diff --git a/contracts/registry-tansu-manager/src/lib.rs b/contracts/registry-tansu-manager/src/lib.rs index beb5e9a..d3aea1c 100644 --- a/contracts/registry-tansu-manager/src/lib.rs +++ b/contracts/registry-tansu-manager/src/lib.rs @@ -115,22 +115,22 @@ pub struct RegistryTansuManager; #[contractimpl] impl RegistryTansuManager { - pub fn __constructor(env: Env, tansu: Address, project_key: Bytes, registry: Address) { - Storage::set_tansu(&env, &tansu); - Storage::set_project_key(&env, &project_key); - Storage::set_registry(&env, ®istry); + pub fn __constructor(env: &Env, tansu: &Address, project_key: &Bytes, registry: &Address) { + Storage::set_tansu(env, tansu); + Storage::set_project_key(env, project_key); + Storage::set_registry(env, registry); } - pub fn tansu(env: Env) -> Address { - Storage::get_tansu(&env).unwrap() + pub fn tansu(env: &Env) -> Address { + Storage::get_tansu(env).unwrap() } - pub fn project_key(env: Env) -> Bytes { - Storage::get_project_key(&env).unwrap() + pub fn project_key(env: &Env) -> Bytes { + Storage::get_project_key(env).unwrap() } - pub fn registry(env: Env) -> Address { - Storage::get_registry(&env).unwrap() + pub fn registry(env: &Env) -> Address { + Storage::get_registry(env).unwrap() } /// Execute a passed Tansu proposal by forwarding its outcome to the registry. @@ -150,18 +150,18 @@ impl RegistryTansuManager { /// so a wrong-project proposal cannot resolve. We do not re-verify /// `project_key` against any field of the returned proposal — Tansu's /// storage layout makes that lookup the only path. - pub fn execute(env: Env, proposal_id: u32) -> Result { - if Storage::has_executed(&env, &proposal_id) { + pub fn execute(env: &Env, proposal_id: u32) -> Result { + if Storage::has_executed(env, &proposal_id) { return Err(Error::AlreadyExecuted); } - let tansu = Storage::get_tansu(&env).unwrap(); - let project_key = Storage::get_project_key(&env).unwrap(); - let registry = Storage::get_registry(&env).unwrap(); + let tansu = Storage::get_tansu(env).unwrap(); + let project_key = Storage::get_project_key(env).unwrap(); + let registry = Storage::get_registry(env).unwrap(); let proposal: Proposal = env.invoke_contract( &tansu, - &Symbol::new(&env, "get_proposal"), - vec![&env, project_key.into_val(&env), proposal_id.into_val(&env)], + &Symbol::new(env, "get_proposal"), + vec![env, project_key.into_val(env), proposal_id.into_val(env)], ); if !matches!(proposal.status, ProposalStatus::Approved) { @@ -179,7 +179,7 @@ impl RegistryTansuManager { } let result: Val = env.invoke_contract(®istry, &oc.execute_fn, oc.args); - Storage::set_executed(&env, &proposal_id, &true); + Storage::set_executed(env, &proposal_id, &true); Ok(result) } } diff --git a/contracts/registry-tansu-manager/src/test.rs b/contracts/registry-tansu-manager/src/test.rs index 7d7ab69..570aedf 100644 --- a/contracts/registry-tansu-manager/src/test.rs +++ b/contracts/registry-tansu-manager/src/test.rs @@ -1,3 +1,5 @@ +#![allow(clippy::needless_pass_by_value, clippy::should_panic_without_expect)] + extern crate std; use soroban_sdk::{ diff --git a/contracts/test/tansu-stub/src/lib.rs b/contracts/test/tansu-stub/src/lib.rs index a8018b4..ed20e32 100644 --- a/contracts/test/tansu-stub/src/lib.rs +++ b/contracts/test/tansu-stub/src/lib.rs @@ -1,5 +1,5 @@ #![no_std] -#![allow(clippy::too_many_arguments)] +#![allow(clippy::too_many_arguments, clippy::needless_pass_by_value)] use soroban_sdk::{ contract, contractimpl, contracttype, vec, Address, Bytes, BytesN, Env, IntoVal, String, @@ -97,7 +97,7 @@ pub struct TansuStub; impl TansuStub { /// Tansu's real `get_proposal(project_key, proposal_id) -> Proposal`. /// The stub stores proposals planted via `set_*_proposal` helpers. - pub fn get_proposal(env: Env, project_key: Bytes, proposal_id: u32) -> Proposal { + pub fn get_proposal(env: &Env, project_key: Bytes, proposal_id: u32) -> Proposal { env.storage() .persistent() .get(&Key::Proposal(project_key, proposal_id)) @@ -111,7 +111,7 @@ impl TansuStub { /// `__constructor(admin: Address)` shape of the `hello` example contract. /// Use [`set_proposal_outcome`] for any other shape. pub fn set_deploy_proposal( - env: Env, + env: &Env, project_key: Bytes, proposal_id: u32, registry: Address, @@ -121,22 +121,22 @@ impl TansuStub { admin: Address, deployer: Option
, ) { - let init: Option> = Some(vec![&env, admin.clone().into_val(&env)]); + let init: Option> = Some(vec![env, admin.clone().into_val(env)]); let args: Vec = vec![ - &env, - wasm_name.into_val(&env), - version.into_val(&env), - contract_name.into_val(&env), - admin.into_val(&env), - init.into_val(&env), - deployer.into_val(&env), + env, + wasm_name.into_val(env), + version.into_val(env), + contract_name.into_val(env), + admin.into_val(env), + init.into_val(env), + deployer.into_val(env), ]; Self::store( - &env, + env, project_key, proposal_id, registry, - Symbol::new(&env, "deploy"), + Symbol::new(env, "deploy"), args, ); } @@ -144,14 +144,14 @@ impl TansuStub { /// Plant a fully custom `Approved` proposal — caller supplies the outcome /// `(target, fn_name, args)` directly. pub fn set_proposal_outcome( - env: Env, + env: &Env, project_key: Bytes, proposal_id: u32, target: Address, fn_name: Symbol, args: Vec, ) { - Self::store(&env, project_key, proposal_id, target, fn_name, args); + Self::store(env, project_key, proposal_id, target, fn_name, args); } fn store( From babc16de20b73be728865e62e440d1c4a01fb044 Mon Sep 17 00:00:00 2001 From: Chad Ostrowski <221614+chadoh@users.noreply.github.com> Date: Wed, 27 May 2026 16:09:16 -0400 Subject: [PATCH 06/16] correct comments about `network alias` Co-authored-by: Chad Ostrowski <221614+chadoh@users.noreply.github.com> --- contracts/registry-tansu-manager/e2e-testnet.sh | 15 ++++----------- 1 file changed, 4 insertions(+), 11 deletions(-) diff --git a/contracts/registry-tansu-manager/e2e-testnet.sh b/contracts/registry-tansu-manager/e2e-testnet.sh index c067f12..a900556 100755 --- a/contracts/registry-tansu-manager/e2e-testnet.sh +++ b/contracts/registry-tansu-manager/e2e-testnet.sh @@ -15,7 +15,7 @@ # # Usage: contracts/registry-tansu-manager/e2e-testnet.sh # Env vars: -# NETWORK Stellar network alias (default: testnet; must be `stellar network add`-ed). +# NETWORK Stellar network alias (default: testnet; must be in `stellar network ls`). # RUN_ID Suffix appended to ephemeral identities/aliases (default: epoch). # PROPOSAL_ID Proposal id to use (default: 1). # HELLO_VERSION Version published for hello (default: 0.1.0). @@ -48,17 +48,10 @@ for w in "$HELLO_WASM" "$REGISTRY_WASM" "$MANAGER_WASM" "$STUB_WASM"; do fi done -# Ensure the network alias exists locally. testnet is added if missing; any -# other name must be pre-configured by the user. +# Ensure the network alias exists locally. if ! stellar network ls 2>/dev/null | grep -qx "$NETWORK"; then - if [ "$NETWORK" = "testnet" ]; then - stellar network add testnet \ - --rpc-url https://soroban-testnet.stellar.org \ - --network-passphrase "Test SDF Network ; September 2015" - else - echo "❌ stellar network '$NETWORK' is not configured; run \`stellar network add\` first" >&2 - exit 1 - fi + echo "❌ stellar network '$NETWORK' is not configured; run \`stellar network add\` first" >&2 + exit 1 fi ADMIN_ID="${ADMIN_ID:-e2e-admin-${RUN_ID}}" From 6a7d7a08b25d2f46735e2488cda5cb7618be8aec Mon Sep 17 00:00:00 2001 From: Willem Wyndham Date: Thu, 28 May 2026 00:24:40 -0400 Subject: [PATCH 07/16] refactor(registry-tansu-manager): use import_contract_client!(tansu_stub) and AuthClient in tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drops the manager's hand-copied Tansu types in favor of a typed `Client` and `Proposal`/`OutcomeContract`/`ProposalStatus` derived from the stub's wasm spec. The tansu-stub crate is now the single source of truth for those types (still hand-mirrored from upstream Consulting-Manao/tansu — collision in their real wasm spec blocks importing it directly: see Consulting-Manao/tansu#152). The registry forward call (`env.invoke_contract(®istry, &oc.execute_fn, oc.args)`) stays untyped on purpose: an approved proposal targets any registry method, so a typed client can't express the arbitrary-method forward. Test-side changes: - Extract the inline RegistryStub to its own `contracts/test/registry-stub` crate so it can be wasm-imported. - Wasm-import the stub via `soroban_sdk_tools::contractimport!` so tests get the `AuthClient` builder, replacing the prior `setup_mock_auth(...)` + hand-built MockAuth in `registry_rejects_direct_caller`. - Drop the inline TansuStub from `test.rs`; use the standalone tansu-stub crate (already used by the testnet e2e script) instead. - Add a generic `set_proposal(project_key, proposal)` helper to tansu-stub so unit tests can plant non-Approved statuses and `None`-outcome shapes. Build order is handled by topo-sorting on Cargo edges: tansu-stub is in the manager's `[dependencies]` (build-only signal — the cdylib never links), and registry-stub is in `[dev-dependencies]` (used only in tests). Pattern mirrors `contracts/registry`'s existing dev-dep on `hello_world`. Verification: `just build` clean; `cargo test -p registry-tansu-manager` 9/9 pass; `cargo clippy ... -- -D warnings` clean; `cargo fmt --check` clean. Addresses PR #518 review threads on lib.rs:165 and tansu-stub/lib.rs:13. Co-Authored-By: Claude Opus 4.7 (1M context) --- contracts/registry-tansu-manager/Cargo.toml | 7 ++ contracts/registry-tansu-manager/src/lib.rs | 103 +++-------------- contracts/registry-tansu-manager/src/test.rs | 114 ++++++------------- contracts/test/tansu-stub/src/lib.rs | 9 ++ 4 files changed, 65 insertions(+), 168 deletions(-) diff --git a/contracts/registry-tansu-manager/Cargo.toml b/contracts/registry-tansu-manager/Cargo.toml index 8d00807..f6f4372 100644 --- a/contracts/registry-tansu-manager/Cargo.toml +++ b/contracts/registry-tansu-manager/Cargo.toml @@ -14,10 +14,17 @@ doctest = false [dependencies] soroban-sdk = { workspace = true } soroban-sdk-tools = "0.1.2" +stellar-registry = { workspace = true } +# Build-order signal so `stellar scaffold build` produces `tansu_stub.wasm` +# before this crate compiles `import_contract_client!(tansu_stub)`. The stub +# is the single source of truth for the Tansu proposal types — pattern mirrors +# `contracts/registry`'s dev-dep on `hello_world`. +tansu-stub = { path = "../test/tansu-stub" } [dev-dependencies] soroban-sdk = { workspace = true, features = ["testutils"] } soroban-sdk-tools = { version = "0.1.2", features = ["testutils"] } +registry-stub = { path = "../test/registry-stub" } [package.metadata.stellar] cargo_inherit = true diff --git a/contracts/registry-tansu-manager/src/lib.rs b/contracts/registry-tansu-manager/src/lib.rs index d3aea1c..fc2803c 100644 --- a/contracts/registry-tansu-manager/src/lib.rs +++ b/contracts/registry-tansu-manager/src/lib.rs @@ -1,9 +1,6 @@ #![no_std] -use soroban_sdk::{ - contract, contractimpl, contracttype, vec, Address, Bytes, BytesN, Env, IntoVal, String, - Symbol, Val, Vec, -}; +use soroban_sdk::{self, contract, contractimpl, Address, Bytes, Env, Val}; use soroban_sdk_tools::{contractstorage, InstanceItem, PersistentMap}; #[soroban_sdk_tools::scerr] @@ -20,83 +17,13 @@ pub enum Error { AlreadyExecuted, } -// Tansu types mirrored from Consulting-Manao/tansu `contracts/tansu/src/types.rs`. -// Field order, variant order, and `#[contracttype]` annotations must stay -// identical to the upstream — Soroban encodes structs/enums by position, so -// silent drift would surface as decode failures at runtime. - -#[contracttype] -#[derive(Clone)] -pub enum VoteChoice { - Approve, - Reject, - Abstain, -} - -#[contracttype] -#[derive(Clone)] -pub struct PublicVote { - pub address: Address, - pub weight: u32, - pub vote_choice: VoteChoice, -} - -#[contracttype] -#[derive(Clone)] -pub struct AnonymousVote { - pub address: Address, - pub weight: u32, - pub encrypted_seeds: Vec, - pub encrypted_votes: Vec, - pub commitments: Vec>, -} - -#[contracttype] -#[derive(Clone)] -#[allow(clippy::large_enum_variant)] -pub enum Vote { - PublicVote(PublicVote), - AnonymousVote(AnonymousVote), -} - -#[contracttype] -#[derive(Clone)] -pub struct VoteData { - pub voting_ends_at: u64, - pub public_voting: bool, - pub token_contract: Option
, - pub votes: Vec, -} - -#[contracttype] -#[derive(Clone)] -pub enum ProposalStatus { - Active, - Approved, - Rejected, - Cancelled, - Malicious, -} - -#[contracttype] -#[derive(Clone)] -pub struct OutcomeContract { - pub address: Address, - pub execute_fn: Symbol, - pub args: Vec, -} - -#[contracttype] -#[derive(Clone)] -pub struct Proposal { - pub id: u32, - pub title: String, - pub proposer: Address, - pub ipfs: String, - pub vote_data: VoteData, - pub status: ProposalStatus, - pub outcome_contracts: Option>, -} +// Tansu proposal types + client come from the `tansu-stub` contract's wasm +// (built by `stellar scaffold build` ahead of this crate via the Cargo edge in +// `[dependencies]`). The stub is the single source of truth for these types, +// hand-mirrored from upstream Tansu — see `contracts/test/tansu-stub/src/lib.rs`. +// At runtime the manager points its `tansu` Address at *real* Tansu; the +// wire-level encoding matches because the stub mirrors Tansu's spec. +stellar_registry::import_contract_client!(tansu_stub); #[contractstorage(auto_shorten = true)] pub struct Storage { @@ -150,6 +77,11 @@ impl RegistryTansuManager { /// so a wrong-project proposal cannot resolve. We do not re-verify /// `project_key` against any field of the returned proposal — Tansu's /// storage layout makes that lookup the only path. + /// + /// The forward call to the registry stays as untyped `env.invoke_contract` + /// rather than a typed client: an approved proposal can target *any* + /// registry method (whatever `execute_fn` + `args` the DAO passed), and a + /// typed client can't express that arbitrary forward. pub fn execute(env: &Env, proposal_id: u32) -> Result { if Storage::has_executed(env, &proposal_id) { return Err(Error::AlreadyExecuted); @@ -158,13 +90,10 @@ impl RegistryTansuManager { let project_key = Storage::get_project_key(env).unwrap(); let registry = Storage::get_registry(env).unwrap(); - let proposal: Proposal = env.invoke_contract( - &tansu, - &Symbol::new(env, "get_proposal"), - vec![env, project_key.into_val(env), proposal_id.into_val(env)], - ); + let proposal = + tansu_stub::Client::new(env, &tansu).get_proposal(&project_key, &proposal_id); - if !matches!(proposal.status, ProposalStatus::Approved) { + if !matches!(proposal.status, tansu_stub::ProposalStatus::Approved) { return Err(Error::NotApproved); } let outcomes = proposal diff --git a/contracts/registry-tansu-manager/src/test.rs b/contracts/registry-tansu-manager/src/test.rs index 570aedf..135105c 100644 --- a/contracts/registry-tansu-manager/src/test.rs +++ b/contracts/registry-tansu-manager/src/test.rs @@ -3,69 +3,23 @@ extern crate std; use soroban_sdk::{ - contract, contractimpl, contracttype, symbol_short, testutils::Address as _, vec, Address, - Bytes, Env, IntoVal, String, Symbol, Vec, + self, symbol_short, testutils::Address as _, vec, Address, Bytes, Env, IntoVal, String, Symbol, + Vec, }; -use soroban_sdk_tools::auth::setup_mock_auth; use crate::{ - Error, OutcomeContract, Proposal, ProposalStatus, RegistryTansuManager, - RegistryTansuManagerClient, VoteData, + tansu_stub::{self, OutcomeContract, Proposal, ProposalStatus, Vote, VoteData}, + Error, RegistryTansuManager, RegistryTansuManagerClient, }; -// Stub Tansu contract: stores one Proposal under (project_key, id) and returns it on get_proposal. - -#[contracttype] -enum TansuStubKey { - Proposal(Bytes, u32), -} - -#[contract] -pub struct TansuStub; - -#[contractimpl] -impl TansuStub { - pub fn set_proposal(env: Env, project_key: Bytes, proposal: Proposal) { - env.storage() - .instance() - .set(&TansuStubKey::Proposal(project_key, proposal.id), &proposal); - } - - pub fn get_proposal(env: Env, project_key: Bytes, proposal_id: u32) -> Proposal { - env.storage() - .instance() - .get(&TansuStubKey::Proposal(project_key, proposal_id)) - .unwrap() - } -} - -// Stub registry: requires manager auth on `manager_only`, records the value. - -#[contracttype] -enum RegStubKey { - Manager, - Recorded, -} - -#[contract] -pub struct RegistryStub; - -#[contractimpl] -impl RegistryStub { - pub fn __constructor(env: Env, manager: Address) { - env.storage().instance().set(&RegStubKey::Manager, &manager); - } - - pub fn manager_only(env: Env, value: u32) -> u32 { - let manager: Address = env.storage().instance().get(&RegStubKey::Manager).unwrap(); - manager.require_auth(); - env.storage().instance().set(&RegStubKey::Recorded, &value); - value - } - - pub fn recorded(env: Env) -> Option { - env.storage().instance().get(&RegStubKey::Recorded) - } +// The `registry-stub` contract is wasm-imported via the soroban-sdk-tools macro +// (not `import_contract_client!`) so we get the test-only `AuthClient` builder +// alongside the regular `Client`. The AuthClient lets the +// `registry_rejects_direct_caller` test below express "outsider tries to call +// manager_only" with a single chained call instead of constructing MockAuth +// scaffolding by hand. +mod registry_stub { + soroban_sdk_tools::contractimport!(file = "../../target/stellar/local/registry_stub.wasm"); } // --------------------------------------------------------------------------- @@ -85,24 +39,16 @@ struct Setup { fn setup() -> Setup { let env = Env::default(); let project_key = Bytes::from_slice(&env, &[7u8; 16]); - let tansu = env.register(TansuStub, ()); + let tansu = env.register(tansu_stub::WASM, ()); - // Pre-compute the manager address so the registry can be constructed with it - // as `manager`. (Registers a transient placeholder, then registers the real - // manager contract at a deterministic address derived from arg hash — simpler: - // register the manager first, then the registry with the manager address.) + // Register the manager with a dummy registry address; the real address is + // patched into instance storage below once the registry-stub is registered. let manager = env.register( RegistryTansuManager, - ( - tansu.clone(), - project_key.clone(), - // dummy registry address; rewritten via instance storage below - Address::generate(&env), - ), + (tansu.clone(), project_key.clone(), Address::generate(&env)), ); - let registry = env.register(RegistryStub, (manager.clone(),)); + let registry = env.register(registry_stub::WASM, (manager.clone(),)); - // Patch the manager's stored registry to the real RegistryStub address. env.as_contract(&manager, || { crate::Storage::set_registry(&env, ®istry); }); @@ -124,7 +70,7 @@ fn empty_vote_data(env: &Env) -> VoteData { voting_ends_at: 0, public_voting: true, token_contract: None, - votes: Vec::new(env), + votes: Vec::::new(env), } } @@ -145,8 +91,7 @@ fn plant_proposal( status, outcome_contracts: outcomes, }; - let client = TansuStubClient::new(env, tansu); - client.set_proposal(project_key, &proposal); + tansu_stub::Client::new(env, tansu).set_proposal(project_key, &proposal); } fn one_outcome(env: &Env, registry: &Address, value: u32) -> Vec { @@ -194,7 +139,7 @@ fn approved_proposal_forwards_to_registry() { assert_eq!(result, 42); - let reg = RegistryStubClient::new(&s.env, &s.registry); + let reg = registry_stub::Client::new(&s.env, &s.registry); assert_eq!(reg.recorded(), Some(42)); } @@ -305,8 +250,7 @@ fn proposal_targeting_wrong_address_is_rejected() { } // --------------------------------------------------------------------------- -// Auth-flow guard: the registry's manager-only function must reject calls -// that come from somewhere other than the manager contract. +// Replay guard // --------------------------------------------------------------------------- #[test] @@ -361,14 +305,22 @@ fn proposal_targeting_manager_itself_is_rejected() { assert_eq!(err, Error::OutcomeTargetMismatch); } +// --------------------------------------------------------------------------- +// Auth-flow guard: the registry's manager-only function must reject calls +// that come from somewhere other than the manager contract. +// --------------------------------------------------------------------------- + #[test] #[should_panic] // require_auth on the manager address fails for an outside caller fn registry_rejects_direct_caller() { let s = setup(); let outsider = Address::generate(&s.env); - // Authorize the outsider (not the manager contract) and call directly. - setup_mock_auth(&s.env, &s.registry, "manager_only", (99u32,), &[&outsider]); - let reg = RegistryStubClient::new(&s.env, &s.registry); - reg.manager_only(&99u32); + // Authorize the outsider (not the manager) and call manager_only directly. + // AuthClient chains the mock-auth setup onto the call in one builder, + // replacing the prior hand-built `setup_mock_auth(...)` + client.invoke(). + registry_stub::AuthClient::new(&s.env, &s.registry) + .manager_only(&99u32) + .authorize(&outsider) + .invoke(); } diff --git a/contracts/test/tansu-stub/src/lib.rs b/contracts/test/tansu-stub/src/lib.rs index ed20e32..6352024 100644 --- a/contracts/test/tansu-stub/src/lib.rs +++ b/contracts/test/tansu-stub/src/lib.rs @@ -104,6 +104,15 @@ impl TansuStub { .unwrap() } + /// Plant an arbitrary, fully-formed `Proposal`. Used by callers that need + /// non-`Approved` states or unusual outcome shapes (e.g. unit tests that + /// exercise every rejection path in `RegistryTansuManager::execute`). + pub fn set_proposal(env: &Env, project_key: Bytes, proposal: Proposal) { + env.storage() + .persistent() + .set(&Key::Proposal(project_key, proposal.id), &proposal); + } + /// Plant an `Approved` proposal whose single outcome is /// `registry.deploy(wasm_name, version, contract_name, admin, init, deployer)`. /// From 1371cc31a58ce28574b763ad4704fe26ca960a45 Mon Sep 17 00:00:00 2001 From: Willem Wyndham Date: Thu, 28 May 2026 09:41:55 -0400 Subject: [PATCH 08/16] test: add real-Tansu testnet e2e (two-phase, 24h voting gap) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The existing `e2e-testnet.sh` runs against the tansu-stub. This new script exercises the same flow against the **live** Tansu DAO on testnet (`CBXKUSLQ…NHGZA`) — the real one chadoh kept asking for, with collateral, voting, and the 24h period. Two phases because `MIN_VOTING_PERIOD` is hardcoded: ./e2e-real-tansu-testnet.sh setup - generates maintainer + voter testnet accounts (auto-funded) - registers a fresh Tansu project (auto-registers the SorobanDomain name on .xlm under the maintainer) - adds maintainer + voter as Tansu members - uploads hello.wasm; deploys a fresh registry (G-account admin/manager); deploys registry-tansu-manager (tansu = live Tansu) - registry.set_manager(manager_contract) — DAO now gates publishing - creates a Tansu proposal whose outcome targets `registry.publish_hash(hello, author, wasm_hash, "0.1.0")` - voter casts Approve (proposer is auto-Abstained by Tansu, hence the two-account model); state saved to a sidecar env file ./e2e-real-tansu-testnet.sh finalize [state-file] # ≥ 24h later - Tansu.execute (Active -> Approved, refunds proposal collateral) - manager.execute -> registry.publish_hash via XCC - asserts registry.fetch_hash returns the wasm hash we uploaded - replay-guards a second manager.execute Phase 1 verified end-to-end on testnet today; phase 2 will be runnable 2026-05-29T13:50:35Z onward. Constraints learned while building this: * Tansu project names: ≤15 chars and `[a-z]+` only (SorobanDomain rule) * Proposal collateral: 70_000_000 stroops (7 XLM); vote collateral 20_000_000 stroops (2 XLM) — both refunded on execute * Tansu auto-adds the proposer to the Abstain group, so single-account runs would deadlock at the vote step * SorobanDomain validates names too; we self-register it via Tansu's `register` (which calls `domain_register` if the node isn't taken) State files are gitignored. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../e2e-real-tansu-testnet.sh | 341 ++++++++++++++++++ 1 file changed, 341 insertions(+) create mode 100755 contracts/registry-tansu-manager/e2e-real-tansu-testnet.sh diff --git a/contracts/registry-tansu-manager/e2e-real-tansu-testnet.sh b/contracts/registry-tansu-manager/e2e-real-tansu-testnet.sh new file mode 100755 index 0000000..1effd39 --- /dev/null +++ b/contracts/registry-tansu-manager/e2e-real-tansu-testnet.sh @@ -0,0 +1,341 @@ +#!/usr/bin/env bash +# End-to-end test of registry-tansu-manager against the **live** Tansu DAO on +# Stellar testnet — exercises the full vote-then-execute cycle, no stub. +# +# Two-phase, because Tansu enforces a 24-hour minimum voting period (hardcoded +# `MIN_VOTING_PERIOD = 24*3600` in contract_dao.rs, no env override): +# +# $ ./e2e-real-tansu-testnet.sh setup +# -> registers a fresh Tansu project, deploys registry + manager, +# uploads hello.wasm, creates a publish_hash proposal on Tansu, +# votes yes, saves state to a sidecar file. +# -> prints the exact follow-up command + timestamp. +# +# $ ./e2e-real-tansu-testnet.sh finalize [state-file] # ≥ 24h later +# -> calls Tansu.execute (Active -> Approved), +# calls manager.execute (forwards to registry.publish_hash), +# verifies the published wasm hash on the registry. +# +# Live Tansu (testnet): CBXKUSLQPVF35FYURR5C42BPYA5UOVDXX2ELKIM2CAJMCI6HXG2BHGZA +# Collateral token (testnet XLM via native SAC): CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC +# Proposal collateral: ~11 XLM (PROPOSAL_COLLATERAL + VOTE_COLLATERAL, badge-based). + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" +WASM_DIR="$REPO_ROOT/target/stellar/local" + +NETWORK="${NETWORK:-testnet}" +TANSU_ID="${TANSU_ID:-CBXKUSLQPVF35FYURR5C42BPYA5UOVDXX2ELKIM2CAJMCI6HXG2BHGZA}" +HELLO_WASM="$WASM_DIR/hello.wasm" +REGISTRY_WASM="$WASM_DIR/registry.wasm" +MANAGER_WASM="$WASM_DIR/registry_tansu_manager.wasm" + +usage() { + cat <&2 +Usage: $0 [state-file] + + setup Run phase 1: register Tansu project, deploy registry + manager, + create + vote on proposal. Writes state to: + $SCRIPT_DIR/e2e-real-tansu-state-.env + finalize Run phase 2 (after voting_ends_at): + $0 finalize [path-to-state-file] + If state-file is omitted, picks the most-recent state file in $SCRIPT_DIR. + +Env: + NETWORK Stellar network alias (default: testnet) + TANSU_ID Tansu contract id (default: live testnet Tansu) +EOF + exit 1 +} + +[[ $# -ge 1 ]] || usage +PHASE="$1"; shift || true + +require_network() { + if ! stellar network ls 2>/dev/null | grep -qx "$NETWORK"; then + echo "❌ stellar network '$NETWORK' is not configured" >&2; exit 1 + fi +} + +ensure_account() { + local id="$1" + if ! stellar keys ls 2>/dev/null | grep -qx "$id"; then + echo "==> Generating + funding $id on $NETWORK" + stellar keys generate --network "$NETWORK" --fund "$id" >/dev/null + fi +} + +invoke() { stellar contract invoke --network "$NETWORK" "$@"; } + +# --------------------------------------------------------------------------- +# Phase 1: setup +# --------------------------------------------------------------------------- +phase_setup() { + require_network + for w in "$HELLO_WASM" "$REGISTRY_WASM" "$MANAGER_WASM"; do + [[ -f "$w" ]] || { echo "❌ missing $w — run \`just build\` first" >&2; exit 1; } + done + + RUN_ID="${RUN_ID:-$(date +%s)}" + STATE_FILE="$SCRIPT_DIR/e2e-real-tansu-state-${RUN_ID}.env" + HELLO_VERSION="${HELLO_VERSION:-0.1.0}" + # Tansu enforces project name ≤ 15 chars. The name is also registered on + # SorobanDomain under TLD .xlm, whose `validate_domain` requires bytes in + # `[a-z]` only — no digits, no hyphens, no uppercase. Map run-id digits to + # the a–j range so we keep determinism + uniqueness. + if [[ -z "${PROJECT_NAME:-}" ]]; then + short_id=$(printf '%s' "$RUN_ID" | tr '0-9' 'a-j') + PROJECT_NAME="ee${short_id: -10}" # 2 + 10 = 12 chars, all lowercase + fi + + MAINTAINER_ID="${MAINTAINER_ID:-tansu-e2e-${RUN_ID}}" + VOTER_ID="${VOTER_ID:-tansu-e2e-voter-${RUN_ID}}" + ensure_account "$MAINTAINER_ID" + ensure_account "$VOTER_ID" + MAINTAINER_ADDR=$(stellar keys address "$MAINTAINER_ID") + VOTER_ADDR=$(stellar keys address "$VOTER_ID") + + echo "==> Network: $NETWORK" + echo "==> Tansu: $TANSU_ID" + echo "==> Run id: $RUN_ID" + echo "==> Maintainer: $MAINTAINER_ID ($MAINTAINER_ADDR)" + echo "==> Voter: $VOTER_ID ($VOTER_ADDR)" + echo "==> State file: $STATE_FILE" + + # 1. Register a fresh project on Tansu. The function returns the project_key + # (Bytes — keccak256(name) inside Tansu). We capture it for the manager + # constructor and proposal-target lookup. + echo "==> Registering Tansu project '$PROJECT_NAME'" + PROJECT_KEY_RAW=$(invoke --id "$TANSU_ID" --source "$MAINTAINER_ID" \ + --send=yes -- register \ + --maintainer "$MAINTAINER_ADDR" \ + --name "$PROJECT_NAME" \ + --maintainers "[\"$MAINTAINER_ADDR\"]" \ + --url "https://example.invalid/${PROJECT_NAME}" \ + --ipfs "QmExampleIpfs0000000000000000000000000000000000") + # Strip quotes from the returned hex Bytes literal. + PROJECT_KEY="${PROJECT_KEY_RAW//\"/}" + echo " project_key: $PROJECT_KEY" + + # 2. Add both maintainer and voter as Tansu members. Tansu auto-adds the + # proposer to the Abstain group on `create_proposal`, so the maintainer + # (proposer) can't be the one casting an Approve — we need a second + # account whose default vote weight of 1 is enough to carry a single- + # voter Approve over the proposer's Abstain. + echo "==> Adding maintainer + voter as Tansu members" + invoke --id "$TANSU_ID" --source "$MAINTAINER_ID" --send=yes \ + -- add_member \ + --member_address "$MAINTAINER_ADDR" \ + --meta "tansu-e2e maintainer" >/dev/null + invoke --id "$TANSU_ID" --source "$VOTER_ID" --send=yes \ + -- add_member \ + --member_address "$VOTER_ADDR" \ + --meta "tansu-e2e voter" >/dev/null + + # 3. Upload hello.wasm to get the hash the proposal will register. + echo "==> Uploading hello.wasm" + HELLO_HASH=$(stellar contract upload --wasm "$HELLO_WASM" \ + --source "$MAINTAINER_ID" --network "$NETWORK") + echo " hash: $HELLO_HASH" + + # 4. Deploy a fresh registry — admin & manager both set to the G account + # initially, so we can swap the manager to the manager contract before + # handing publishing power over to the DAO. + echo "==> Deploying registry (admin=manager=$MAINTAINER_ID)" + REGISTRY_ID=$(stellar contract deploy --wasm "$REGISTRY_WASM" \ + --source "$MAINTAINER_ID" --network "$NETWORK" \ + --alias "registry-tansu-e2e-${RUN_ID}" \ + -- --admin "$MAINTAINER_ADDR" --manager "\"$MAINTAINER_ADDR\"") + echo " registry: $REGISTRY_ID" + + # 5. Deploy registry-tansu-manager, pointing at LIVE Tansu + our registry. + echo "==> Deploying registry-tansu-manager" + MANAGER_ID=$(stellar contract deploy --wasm "$MANAGER_WASM" \ + --source "$MAINTAINER_ID" --network "$NETWORK" \ + --alias "manager-tansu-e2e-${RUN_ID}" \ + -- \ + --tansu "$TANSU_ID" \ + --project_key "$PROJECT_KEY" \ + --registry "$REGISTRY_ID") + echo " manager: $MANAGER_ID" + + # 6. Swap registry's manager to the manager contract. From here on, all + # manager-gated registry ops MUST come through Tansu proposals. + echo "==> Installing manager contract on registry" + invoke --id "$REGISTRY_ID" --source "$MAINTAINER_ID" --send=yes \ + -- set_manager --new_manager "$MANAGER_ID" >/dev/null + + # 7. Build the proposal. Outcome targets registry.publish_hash(hello) so a + # successful proposal lands hello.wasm in the registry under the DAO's + # authority. + NOW=$(date +%s) + VOTING_ENDS_AT=$((NOW + 24*3600 + 600)) # 24h + 10min cushion + PROPOSAL_TITLE="${PROPOSAL_TITLE:-Add hello@${HELLO_VERSION} to registry}" + # publish_hash(wasm_name: String, author: Address, wasm_hash: BytesN<32>, version: String) + # version is a plain String (not Option) — confirmed from registry.wasm interface. + OUTCOME=$(cat < Creating Tansu proposal (voting_ends_at=$VOTING_ENDS_AT)" + PROPOSAL_ID_RAW=$(invoke --id "$TANSU_ID" --source "$MAINTAINER_ID" --send=yes \ + -- create_proposal \ + --proposer "$MAINTAINER_ADDR" \ + --project_key "$PROJECT_KEY" \ + --title "$PROPOSAL_TITLE" \ + --ipfs "QmExampleIpfs1111111111111111111111111111111111" \ + --voting_ends_at "$VOTING_ENDS_AT" \ + --public_voting true \ + --outcome_contracts "$OUTCOME") + PROPOSAL_ID="${PROPOSAL_ID_RAW//\"/}" + echo " proposal_id: $PROPOSAL_ID" + + # 8. Vote Approve as the second account. The proposer was auto-added to + # Abstain when `create_proposal` ran; this Approve out-votes that. + echo "==> Voting Approve as $VOTER_ID" + VOTE_PAYLOAD=$(cat </dev/null + + # 9. Save state for the finalize phase. + cat > "$STATE_FILE" </dev/null | head -n1 || true) + fi + [[ -n "$state_file" && -f "$state_file" ]] || { + echo "❌ no state file (looked in $SCRIPT_DIR/e2e-real-tansu-state-*.env)" >&2; exit 1 + } + # shellcheck source=/dev/null + source "$state_file" + echo "==> State file: $state_file" + echo "==> Run id: $RUN_ID" + + NOW=$(date +%s) + if (( NOW < VOTING_ENDS_AT )); then + echo "❌ Voting period hasn't ended yet. Earliest: $(date -u -d "@$VOTING_ENDS_AT" +%FT%TZ) (in $(( (VOTING_ENDS_AT - NOW) / 60 ))m)" >&2 + exit 1 + fi + + # 1. Tansu.execute moves the proposal from Active to Approved if votes pass. + echo "==> Tansu.execute (Active -> Approved)" + STATUS_RAW=$(invoke --id "$TANSU_ID" --source "$MAINTAINER_ID" --send=yes \ + -- execute \ + --maintainer "$MAINTAINER_ADDR" \ + --project_key "$PROJECT_KEY" \ + --proposal_id "$PROPOSAL_ID") + STATUS="${STATUS_RAW//\"/}" + echo " status: $STATUS" + if [[ "$STATUS" != "Approved" ]]; then + echo "❌ proposal did not pass — Tansu returned: $STATUS" >&2; exit 1 + fi + + # 2. Manager.execute reads the now-Approved proposal from Tansu via + # get_proposal, then forwards the outcome (registry.publish_hash) via XCC. + echo "==> manager.execute -> registry.publish_hash via XCC" + invoke --id "$MANAGER_ID" --source "$MAINTAINER_ID" --send=yes \ + -- execute --proposal_id "$PROPOSAL_ID" >/dev/null + + # 3. Verify the registry now has hello@version pointing at our uploaded hash. + echo "==> Verifying registry has hello@$HELLO_VERSION -> $HELLO_HASH" + PUBLISHED_HASH_RAW=$(invoke --id "$REGISTRY_ID" --source "$MAINTAINER_ID" \ + -- fetch_hash --wasm_name hello --version "\"$HELLO_VERSION\"") + PUBLISHED_HASH="${PUBLISHED_HASH_RAW//\"/}" + if [[ "$PUBLISHED_HASH" == "$HELLO_HASH" ]]; then + echo " ✓ registry resolved hello@$HELLO_VERSION -> $PUBLISHED_HASH" + else + echo " ❌ registry returned $PUBLISHED_HASH, expected $HELLO_HASH" >&2 + exit 1 + fi + + # 4. Replay guard. + echo "==> Replay check — second manager.execute must fail with AlreadyExecuted" + REPLAY_OUT=$(invoke --id "$MANAGER_ID" --source "$MAINTAINER_ID" --send=yes \ + -- execute --proposal_id "$PROPOSAL_ID" 2>&1 || true) + if grep -qE 'AlreadyExecuted|Error\(Contract, ?#5\)' <<<"$REPLAY_OUT"; then + echo " ✓ replay rejected" + else + echo " ❌ replay was NOT rejected" >&2 + echo "$REPLAY_OUT" >&2 + exit 1 + fi + + cat < $STATUS + hello: $HELLO_HASH @ $HELLO_VERSION +EOF +} + +case "$PHASE" in + setup) phase_setup "$@" ;; + finalize) phase_finalize "$@" ;; + *) usage ;; +esac From d36ff027dc53d3043b66b54118aac3dcb724bd52 Mon Sep 17 00:00:00 2001 From: Willem Wyndham Date: Thu, 28 May 2026 10:14:38 -0400 Subject: [PATCH 09/16] fix(ci): mark tansu-stub as a contract dep so topo sort builds it before manager `stellar scaffold build` orders contract compilation by walking Cargo edges where the dep has `[package.metadata.stellar] contract = true`. Without that flag on tansu-stub, the manager (which `import_contract_client!`s tansu_stub.wasm in non-test code) could be built before the stub. Locally this happened to work because `target/stellar/local/tansu_stub.wasm` was lying around from earlier builds; CI's clean checkout had no such fallback and the macro then tried `stellar registry download tansu_stub`, which is not a subcommand the CI's stellar-cli has. Verified by removing `target/stellar/local/tansu_stub.wasm` and `registry_tansu_manager.wasm` then running `just build` end-to-end clean. Co-Authored-By: Claude Opus 4.7 (1M context) --- contracts/test/tansu-stub/Cargo.toml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/contracts/test/tansu-stub/Cargo.toml b/contracts/test/tansu-stub/Cargo.toml index 4997359..98ccbde 100644 --- a/contracts/test/tansu-stub/Cargo.toml +++ b/contracts/test/tansu-stub/Cargo.toml @@ -14,3 +14,9 @@ soroban-sdk = { workspace = true } [package.metadata.stellar] cargo_inherit = true +# Marks this crate as a contract-build dependency so `stellar scaffold build` +# orders it before any package that lists it under `[dependencies]` (e.g. +# `registry-tansu-manager`, which `import_contract_client!`s tansu_stub.wasm +# at compile time). Without this, the topo sort can't see the edge and a +# clean-checkout build (CI) fails because tansu_stub.wasm isn't there yet. +contract = true From a420ee9b7a3feb1dd6ab91069a9ad31c06dd2d22 Mon Sep 17 00:00:00 2001 From: Willem Wyndham Date: Thu, 28 May 2026 10:23:53 -0400 Subject: [PATCH 10/16] docs(e2e): add Stellar Expert link for live Tansu + clarify collateral amounts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Header reflow: the Tansu testnet contract now carries its explorer link, and the collateral note distinguishes PROPOSAL_COLLATERAL (7 XLM, paid at create_proposal) from VOTE_COLLATERAL (2 XLM, paid per vote) — the earlier "~11 XLM" was a single rough sum. Both are refunded on Tansu.execute. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../registry-tansu-manager/e2e-real-tansu-testnet.sh | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/contracts/registry-tansu-manager/e2e-real-tansu-testnet.sh b/contracts/registry-tansu-manager/e2e-real-tansu-testnet.sh index 1effd39..5fa74ef 100755 --- a/contracts/registry-tansu-manager/e2e-real-tansu-testnet.sh +++ b/contracts/registry-tansu-manager/e2e-real-tansu-testnet.sh @@ -16,9 +16,13 @@ # calls manager.execute (forwards to registry.publish_hash), # verifies the published wasm hash on the registry. # -# Live Tansu (testnet): CBXKUSLQPVF35FYURR5C42BPYA5UOVDXX2ELKIM2CAJMCI6HXG2BHGZA -# Collateral token (testnet XLM via native SAC): CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC -# Proposal collateral: ~11 XLM (PROPOSAL_COLLATERAL + VOTE_COLLATERAL, badge-based). +# Live Tansu (testnet): +# CBXKUSLQPVF35FYURR5C42BPYA5UOVDXX2ELKIM2CAJMCI6HXG2BHGZA +# https://stellar.expert/explorer/testnet/contract/CBXKUSLQPVF35FYURR5C42BPYA5UOVDXX2ELKIM2CAJMCI6HXG2BHGZA +# Collateral token (testnet XLM via native SAC): +# CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC +# Proposal collateral: 7 XLM (PROPOSAL_COLLATERAL); voting also takes 2 XLM +# per voter (VOTE_COLLATERAL). Both refunded on Tansu.execute. set -euo pipefail From d292e07da6ae03ba740a0aae7b482bc92e112174 Mon Sep 17 00:00:00 2001 From: Willem Wyndham Date: Thu, 28 May 2026 14:34:56 -0400 Subject: [PATCH 11/16] checkpoint: no-op proxy design before __check_auth refactor Saves a working state where the manager has typed no-op proxy methods (`publish_hash`, `manager_only`) that Tansu's auto-invocation lands on, and `execute(proposal_id)` re-reads the proposal and forwards `oc.execute_fn + oc.args` to the registry with the manager's auth. Fast e2e against custom Tansu (CDK7JBII...XJ26UON) passes end-to-end in ~2.5min. Unit tests 9/9 green. About to refactor to custom-account `__check_auth` design which collapses this to a single tx and ties auth to the Tansu.execute call chain cryptographically. Tagging here in case we need to revert. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../e2e-fast-tansu-testnet.sh | 269 ++++++++++++++++++ .../e2e-real-tansu-testnet.sh | 14 +- contracts/registry-tansu-manager/src/lib.rs | 70 +++-- contracts/registry-tansu-manager/src/test.rs | 22 +- 4 files changed, 341 insertions(+), 34 deletions(-) create mode 100755 contracts/registry-tansu-manager/e2e-fast-tansu-testnet.sh diff --git a/contracts/registry-tansu-manager/e2e-fast-tansu-testnet.sh b/contracts/registry-tansu-manager/e2e-fast-tansu-testnet.sh new file mode 100755 index 0000000..e41b2ca --- /dev/null +++ b/contracts/registry-tansu-manager/e2e-fast-tansu-testnet.sh @@ -0,0 +1,269 @@ +#!/usr/bin/env bash +# Fast e2e against a custom-built Tansu that exposes per-project +# `min_voting_period` AND `execute_delay` on `register(...)`. Lets us exercise +# the same flow as e2e-real-tansu-testnet.sh in ~2 minutes instead of 48+ hours +# (stock Tansu has 24h MIN_VOTING_PERIOD + 24h TIMELOCK_DELAY). +# +# Custom Tansu (testnet): CDK7JBIIP6E75HOYLGRGWAHQLT6JUNUXQ7GNOYS3NAP26GISUXJ26UON +# https://stellar.expert/explorer/testnet/contract/CDK7JBIIP6E75HOYLGRGWAHQLT6JUNUXQ7GNOYS3NAP26GISUXJ26UON +# +# Flow (single phase): +# 1. Register a fresh Tansu project with min_voting_period=$MIN_VOTING_PERIOD seconds +# 2. Add maintainer + voter as Tansu members +# 3. Upload hello.wasm, deploy registry, deploy manager, set_manager +# 4. Create proposal whose outcome targets `manager.publish_hash(...)` (a +# no-op proxy on the manager, same signature as the registry's). Tansu's +# auto-invocation in `contract_dao.rs::execute` lands on this no-op +# instead of the registry — pointing the outcome at the registry +# directly would fail at the registry's `manager.require_auth` (Tansu +# isn't in that auth chain) and `try_invoke_contract` would propagate +# the failure, reverting the whole Tansu tx. +# 5. Vote Approve from the second account +# 6. Sleep until past voting_ends_at + execute_delay +# 7. Tansu.execute (Active -> Approved). The no-op proxy succeeds; the +# proposal status persists. +# 8. manager.execute(proposal_id) — re-reads the now-Approved proposal, +# checks the outcome targets one of its proxies (and isn't a recursive +# `execute` re-entry), then forwards `oc.execute_fn + oc.args` to the +# registry with this contract's auth satisfying manager.require_auth. +# 9. Assert registry.fetch_hash returns the wasm hash we uploaded +# 10. Replay guard via second manager.execute +# +# Env (all optional): +# NETWORK Stellar network alias (default: testnet) +# TANSU_ID Tansu contract id (default: custom-built testnet Tansu above) +# MIN_VOTING_PERIOD Seconds. Default 60. Must be ≥ ~30s to leave room for tx propagation. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" +WASM_DIR="$REPO_ROOT/target/stellar/local" + +NETWORK="${NETWORK:-testnet}" +TANSU_ID="${TANSU_ID:-CDK7JBIIP6E75HOYLGRGWAHQLT6JUNUXQ7GNOYS3NAP26GISUXJ26UON}" +MIN_VOTING_PERIOD="${MIN_VOTING_PERIOD:-60}" +# Seconds between voting_ends_at and when Tansu.execute is callable. The custom +# Tansu rejects 0 (InvalidVotingPeriod / #212) — any positive value is fine. +EXECUTE_DELAY="${EXECUTE_DELAY:-60}" +HELLO_VERSION="${HELLO_VERSION:-0.1.0}" +RUN_ID="${RUN_ID:-$(date +%s)}" + +HELLO_WASM="$WASM_DIR/hello.wasm" +REGISTRY_WASM="$WASM_DIR/registry.wasm" +MANAGER_WASM="$WASM_DIR/registry_tansu_manager.wasm" + +for w in "$HELLO_WASM" "$REGISTRY_WASM" "$MANAGER_WASM"; do + [[ -f "$w" ]] || { echo "❌ missing $w — run \`just build\` first" >&2; exit 1; } +done + +if ! stellar network ls 2>/dev/null | grep -qx "$NETWORK"; then + echo "❌ stellar network '$NETWORK' is not configured" >&2; exit 1 +fi + +ensure_account() { + local id="$1" + if ! stellar keys ls 2>/dev/null | grep -qx "$id"; then + echo "==> Generating + funding $id on $NETWORK" + stellar keys generate --network "$NETWORK" --fund "$id" >/dev/null + fi +} + +invoke() { stellar contract invoke --network "$NETWORK" "$@"; } + +MAINTAINER_ID="${MAINTAINER_ID:-tansu-fast-${RUN_ID}}" +VOTER_ID="${VOTER_ID:-tansu-fast-voter-${RUN_ID}}" +ensure_account "$MAINTAINER_ID" +ensure_account "$VOTER_ID" +MAINTAINER_ADDR=$(stellar keys address "$MAINTAINER_ID") +VOTER_ADDR=$(stellar keys address "$VOTER_ID") + +# Tansu name validation (SorobanDomain): ≤15 chars, [a-z] only. Map run-id digits → a-j. +short_id=$(printf '%s' "$RUN_ID" | tr '0-9' 'a-j') +PROJECT_NAME="${PROJECT_NAME:-ff${short_id: -10}}" + +echo "==> Network: $NETWORK" +echo "==> Tansu (custom): $TANSU_ID" +echo "==> Run id: $RUN_ID" +echo "==> Maintainer: $MAINTAINER_ID ($MAINTAINER_ADDR)" +echo "==> Voter: $VOTER_ID ($VOTER_ADDR)" +echo "==> min_voting_period: ${MIN_VOTING_PERIOD}s" +echo "==> execute_delay: ${EXECUTE_DELAY}s" + +# 1. Register project with short min_voting_period + execute_delay. +echo "==> Registering project '$PROJECT_NAME'" +PROJECT_KEY_RAW=$(invoke --id "$TANSU_ID" --source "$MAINTAINER_ID" --send=yes \ + -- register \ + --maintainer "$MAINTAINER_ADDR" \ + --name "$PROJECT_NAME" \ + --maintainers "[\"$MAINTAINER_ADDR\"]" \ + --url "https://example.invalid/${PROJECT_NAME}" \ + --ipfs "QmExampleIpfs0000000000000000000000000000000000" \ + --min_voting_period "$MIN_VOTING_PERIOD" \ + --execute_delay "$EXECUTE_DELAY") +PROJECT_KEY="${PROJECT_KEY_RAW//\"/}" +echo " project_key: $PROJECT_KEY" + +# Sanity-check: confirm both per-project knobs took. +ACTUAL_MVP=$(invoke --id "$TANSU_ID" --source "$MAINTAINER_ID" \ + -- get_min_voting_period --project_key "$PROJECT_KEY") +ACTUAL_EXD=$(invoke --id "$TANSU_ID" --source "$MAINTAINER_ID" \ + -- get_execute_delay --project_key "$PROJECT_KEY") +echo " confirmed: min_voting_period=${ACTUAL_MVP}s execute_delay=${ACTUAL_EXD}s" + +# 2. Members. Already-existing members from a prior run will trip MemberAlreadyExist; +# ignore that case so this script can be re-run with sticky $MAINTAINER_ID/$VOTER_ID. +echo "==> Adding maintainer + voter as members" +for who in "$MAINTAINER_ID:$MAINTAINER_ADDR:maintainer" "$VOTER_ID:$VOTER_ADDR:voter"; do + IFS=: read -r src addr role <<<"$who" + out=$(invoke --id "$TANSU_ID" --source "$src" --send=yes \ + -- add_member --member_address "$addr" --meta "tansu-fast $role" 2>&1 || true) + if grep -q "MemberAlreadyExist\|#205" <<<"$out"; then + echo " $role $addr: already a member (ok)" + elif grep -q "✅ Transaction submitted successfully" <<<"$out"; then + echo " $role $addr: added" + else + echo "$out" >&2 + echo "❌ add_member failed for $role" >&2 + exit 1 + fi +done + +# 3. Upload hello.wasm. +echo "==> Uploading hello.wasm" +HELLO_HASH=$(stellar contract upload --wasm "$HELLO_WASM" \ + --source "$MAINTAINER_ID" --network "$NETWORK") +echo " hash: $HELLO_HASH" + +# 4. Deploy registry (admin=manager=$MAINTAINER initially) and the manager contract. +echo "==> Deploying registry" +REGISTRY_ID=$(stellar contract deploy --wasm "$REGISTRY_WASM" \ + --source "$MAINTAINER_ID" --network "$NETWORK" \ + --alias "registry-tansu-fast-${RUN_ID}" \ + -- --admin "$MAINTAINER_ADDR" --manager "\"$MAINTAINER_ADDR\"") +echo " registry: $REGISTRY_ID" + +echo "==> Deploying registry-tansu-manager" +MANAGER_ID=$(stellar contract deploy --wasm "$MANAGER_WASM" \ + --source "$MAINTAINER_ID" --network "$NETWORK" \ + --alias "manager-tansu-fast-${RUN_ID}" \ + -- \ + --tansu "$TANSU_ID" \ + --project_key "$PROJECT_KEY" \ + --registry "$REGISTRY_ID") +echo " manager: $MANAGER_ID" + +echo "==> Installing manager contract on registry" +invoke --id "$REGISTRY_ID" --source "$MAINTAINER_ID" --send=yes \ + -- set_manager --new_manager "$MANAGER_ID" >/dev/null + +# 5. Create proposal whose outcome targets registry.publish_hash(hello). +NOW=$(date +%s) +VOTING_ENDS_AT=$((NOW + MIN_VOTING_PERIOD + 15)) # +15s buffer for tx propagation +echo "==> Creating proposal (voting_ends_at=$VOTING_ENDS_AT, in ~$((VOTING_ENDS_AT-NOW))s)" +# Outcome targets the manager's no-op `publish_hash` proxy (same signature as +# the registry's). Tansu's auto-invocation lands there harmlessly so the +# proposal can flip to Approved; manager.execute(proposal_id) then re-reads +# the same outcome and forwards `publish_hash + args` to the registry with +# this contract's auth. +OUTCOME=$(cat < Voting Approve as voter" +VOTE_PAYLOAD=$(cat </dev/null + +# 7. Wait until voting_ends_at + execute_delay + a slack for ledger time lag. +WAIT_UNTIL=$((VOTING_ENDS_AT + EXECUTE_DELAY + 20)) +while (( $(date +%s) < WAIT_UNTIL )); do + remain=$((WAIT_UNTIL - $(date +%s))) + printf "\r==> Waiting for voting period + execute_delay (%ds remaining)... " "$remain" + sleep 5 +done +echo "" + +# 8. Tansu.execute moves the proposal from Active to Approved. +echo "==> Tansu.execute (Active -> Approved)" +STATUS_RAW=$(invoke --id "$TANSU_ID" --source "$MAINTAINER_ID" --send=yes \ + -- execute \ + --maintainer "$MAINTAINER_ADDR" \ + --project_key "$PROJECT_KEY" \ + --proposal_id "$PROPOSAL_ID") +STATUS="${STATUS_RAW//\"/}" +echo " status: $STATUS" +[[ "$STATUS" == "Approved" ]] || { echo "❌ proposal didn't pass: $STATUS" >&2; exit 1; } + +# 9. manager.execute -> registry.publish_hash via XCC. Manager re-reads the +# Approved proposal from Tansu, verifies the outcome targets one of its +# no-op proxies (not `execute` itself), and forwards execute_fn + args to +# the registry. +echo "==> manager.execute -> registry.publish_hash" +invoke --id "$MANAGER_ID" --source "$MAINTAINER_ID" --send=yes \ + -- execute --proposal_id "$PROPOSAL_ID" >/dev/null + +# 10. Verify the publish landed. +echo "==> Verifying registry has hello@$HELLO_VERSION -> $HELLO_HASH" +PUBLISHED_HASH_RAW=$(invoke --id "$REGISTRY_ID" --source "$MAINTAINER_ID" \ + -- fetch_hash --wasm_name hello --version "\"$HELLO_VERSION\"") +PUBLISHED_HASH="${PUBLISHED_HASH_RAW//\"/}" +if [[ "$PUBLISHED_HASH" == "$HELLO_HASH" ]]; then + echo " ✓ registry resolved hello@$HELLO_VERSION -> $PUBLISHED_HASH" +else + echo " ❌ registry returned $PUBLISHED_HASH, expected $HELLO_HASH" >&2 + exit 1 +fi + +# 11. Replay guard. +echo "==> Replay check — second manager.execute must fail with AlreadyExecuted" +REPLAY_OUT=$(invoke --id "$MANAGER_ID" --source "$MAINTAINER_ID" --send=yes \ + -- execute --proposal_id "$PROPOSAL_ID" 2>&1 || true) +if grep -qE 'AlreadyExecuted|Error\(Contract, ?#5\)' <<<"$REPLAY_OUT"; then + echo " ✓ replay rejected" +else + echo " ❌ replay was NOT rejected" >&2 + echo "$REPLAY_OUT" >&2 + exit 1 +fi + +cat < $STATUS + hello: $HELLO_HASH @ $HELLO_VERSION +EOF diff --git a/contracts/registry-tansu-manager/e2e-real-tansu-testnet.sh b/contracts/registry-tansu-manager/e2e-real-tansu-testnet.sh index 5fa74ef..b4fabf6 100755 --- a/contracts/registry-tansu-manager/e2e-real-tansu-testnet.sh +++ b/contracts/registry-tansu-manager/e2e-real-tansu-testnet.sh @@ -171,17 +171,19 @@ phase_setup() { invoke --id "$REGISTRY_ID" --source "$MAINTAINER_ID" --send=yes \ -- set_manager --new_manager "$MANAGER_ID" >/dev/null - # 7. Build the proposal. Outcome targets registry.publish_hash(hello) so a - # successful proposal lands hello.wasm in the registry under the DAO's - # authority. + # 7. Build the proposal. Outcome targets the manager's no-op `publish_hash` + # proxy (same signature as the registry's). Tansu auto-invokes outcomes + # inline; targeting the registry directly would fail at + # `manager.require_auth` because Tansu isn't in that auth chain — this + # manager is. The finalize phase then calls `manager.execute(proposal_id)` + # which re-reads the same outcome and forwards `execute_fn + args` to + # the registry with this contract's auth. NOW=$(date +%s) VOTING_ENDS_AT=$((NOW + 24*3600 + 600)) # 24h + 10min cushion PROPOSAL_TITLE="${PROPOSAL_TITLE:-Add hello@${HELLO_VERSION} to registry}" - # publish_hash(wasm_name: String, author: Address, wasm_hash: BytesN<32>, version: String) - # version is a plain String (not Option) — confirmed from registry.wasm interface. OUTCOME=$(cat < Result { if Storage::has_executed(env, &proposal_id) { return Err(Error::AlreadyExecuted); @@ -103,14 +106,45 @@ impl RegistryTansuManager { return Err(Error::MultipleOutcomes); } let oc = outcomes.get(0).unwrap(); - if oc.address != registry { + if oc.address != env.current_contract_address() { + return Err(Error::OutcomeTargetMismatch); + } + if oc.execute_fn == Symbol::new(env, "execute") { + // Don't let a crafted outcome recurse back into us. return Err(Error::OutcomeTargetMismatch); } - let result: Val = env.invoke_contract(®istry, &oc.execute_fn, oc.args); Storage::set_executed(env, &proposal_id, &true); + let result: Val = env.invoke_contract(®istry, &oc.execute_fn, oc.args); Ok(result) } + + // ----------------------------------------------------------------------- + // No-op proxy methods. + // + // Each one mirrors the signature of a registry method we want to gate + // behind a Tansu proposal. The proposal's outcome targets one of these by + // name + args; Tansu's auto-invocation lands here (does nothing); then + // `execute(proposal_id)` re-reads the same outcome and forwards + // `execute_fn + args` to the registry with this contract's auth chain. + // + // Adding support for another gated registry method = add another no-op + // proxy below with the matching signature. `execute` itself is unchanged. + // ----------------------------------------------------------------------- + + /// No-op proxy for `Registry::publish_hash`. + pub fn publish_hash( + _env: &Env, + _wasm_name: String, + _author: Address, + _wasm_hash: BytesN<32>, + _version: String, + ) { + } + + /// No-op proxy used by unit tests that exercise the forward path against + /// the `registry-stub` fixture's `manager_only(value: u32)` method. + pub fn manager_only(_env: &Env, _value: u32) {} } #[cfg(test)] diff --git a/contracts/registry-tansu-manager/src/test.rs b/contracts/registry-tansu-manager/src/test.rs index 135105c..317866f 100644 --- a/contracts/registry-tansu-manager/src/test.rs +++ b/contracts/registry-tansu-manager/src/test.rs @@ -94,11 +94,11 @@ fn plant_proposal( tansu_stub::Client::new(env, tansu).set_proposal(project_key, &proposal); } -fn one_outcome(env: &Env, registry: &Address, value: u32) -> Vec { +fn one_outcome(env: &Env, manager: &Address, value: u32) -> Vec { vec![ env, OutcomeContract { - address: registry.clone(), + address: manager.clone(), execute_fn: symbol_short!("man_only"), args: vec![env, value.into_val(env)], }, @@ -115,7 +115,7 @@ fn approved_proposal_forwards_to_registry() { let outcomes = vec![ &s.env, OutcomeContract { - address: s.registry.clone(), + address: s.manager.clone(), execute_fn: Symbol::new(&s.env, "manager_only"), args: vec![&s.env, 42u32.into_val(&s.env)], }, @@ -201,12 +201,12 @@ fn proposal_with_multiple_outcomes_is_rejected() { let outcomes = vec![ &s.env, OutcomeContract { - address: s.registry.clone(), + address: s.manager.clone(), execute_fn: Symbol::new(&s.env, "manager_only"), args: vec![&s.env, 1u32.into_val(&s.env)], }, OutcomeContract { - address: s.registry.clone(), + address: s.manager.clone(), execute_fn: Symbol::new(&s.env, "manager_only"), args: vec![&s.env, 2u32.into_val(&s.env)], }, @@ -259,7 +259,7 @@ fn approved_proposal_cannot_be_replayed() { let outcomes = vec![ &s.env, OutcomeContract { - address: s.registry.clone(), + address: s.manager.clone(), execute_fn: Symbol::new(&s.env, "manager_only"), args: vec![&s.env, 7u32.into_val(&s.env)], }, @@ -279,10 +279,12 @@ fn approved_proposal_cannot_be_replayed() { } #[test] -fn proposal_targeting_manager_itself_is_rejected() { - // An attacker-crafted proposal whose outcome address is the manager - // contract (not the registry) must be rejected — otherwise the manager - // could be tricked into recursively re-entering itself. +fn proposal_recursing_into_execute_is_rejected() { + // The new design *requires* outcome.address to be the manager itself (so + // Tansu's auto-invocation lands on a local no-op proxy instead of the + // registry). But the outcome.execute_fn must still NOT be `execute` — an + // attacker-crafted proposal could otherwise loop the manager back into + // itself. `execute` is rejected explicitly to break that. let s = setup(); let outcomes = vec![ &s.env, From ab959308cc854fe5a9799f66c2c0fe5e57c22c45 Mon Sep 17 00:00:00 2001 From: Willem Wyndham Date: Thu, 28 May 2026 16:08:22 -0400 Subject: [PATCH 12/16] refactor(registry-tansu-manager): switch to manager.trigger + authorize_as_current_contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the no-op-proxy + separate manager.execute pattern with a single `trigger(proposal_id)` entry point. Flow: 1. trigger reads the proposal from the configured Tansu under the configured project_key — wrong-project callers can't piggyback. 2. Pre-authorizes *this contract's auth* for exactly the proposal's single approved-branch outcome via env.authorize_as_current_contract. Nothing else gets authorized. 3. Calls Tansu.execute(self, project_key, proposal_id, _, _). Tansu tallies, flips to Approved, auto-invokes the outcome (e.g. registry.publish_hash). The pre-authorization satisfies the registry's manager.require_auth — publish runs in the same tx. Deployment requirement: the manager must be a Tansu project maintainer (set via Tansu::register or update_config). That makes the manager the direct caller of Tansu.execute, so Tansu's internal maintainer.require_auth is satisfied by contract-implicit auth — no auth entry needed, no recording-mode non-root auth issue. Why this over a custom-account `__check_auth`: stellar-cli 26.0.0 does not expose `enable_non_root_authorization`, so a pure __check_auth design fails simulation in recording-auth mode (deep require_auth not tied to the root invocation). authorize_as_current_contract is the production soroban-sdk primitive for this — same security guarantee (manager only authorizes publishes for proposals from its own DAO), works with the current CLI and frontend SDKs without extra plumbing. Storage::executed (replay guard) removed — Tansu's own `if proposal.status != Active { panic }` inside execute prevents the same proposal being driven twice, so the on-manager replay map was redundant. Tests: replaced the inline TansuStub/RegistryStub unit tests with a constructor smoke test. The contracts/test/registry-stub crate is removed (no callers). The integration behavior is covered by contracts/registry-tansu-manager/e2e-fast-tansu-testnet.sh on testnet. E2E updates: - Both e2e scripts now include a Tansu.update_config call to hand maintainership to the manager contract after deploy. - Both finalize/run phases call manager.trigger(proposal_id) and verify the publish landed via registry.fetch_hash. - Replay check runs trigger a second time and asserts Tansu's ProposalActive (#402). Verification: e2e-fast-tansu-testnet.sh against custom Tansu CDK7JBII...XJ26UON ran end-to-end in ~2.5min; replay rejected. Single Tansu.execute tx (~50M stroops gas) drives the whole flow. Co-Authored-By: Claude Opus 4.7 (1M context) --- contracts/registry-tansu-manager/Cargo.toml | 1 - .../e2e-fast-tansu-testnet.sh | 94 +++-- .../e2e-real-tansu-testnet.sh | 66 ++-- contracts/registry-tansu-manager/src/lib.rs | 169 +++++---- contracts/registry-tansu-manager/src/test.rs | 331 +----------------- 5 files changed, 176 insertions(+), 485 deletions(-) diff --git a/contracts/registry-tansu-manager/Cargo.toml b/contracts/registry-tansu-manager/Cargo.toml index f6f4372..d90d79b 100644 --- a/contracts/registry-tansu-manager/Cargo.toml +++ b/contracts/registry-tansu-manager/Cargo.toml @@ -24,7 +24,6 @@ tansu-stub = { path = "../test/tansu-stub" } [dev-dependencies] soroban-sdk = { workspace = true, features = ["testutils"] } soroban-sdk-tools = { version = "0.1.2", features = ["testutils"] } -registry-stub = { path = "../test/registry-stub" } [package.metadata.stellar] cargo_inherit = true diff --git a/contracts/registry-tansu-manager/e2e-fast-tansu-testnet.sh b/contracts/registry-tansu-manager/e2e-fast-tansu-testnet.sh index e41b2ca..9d22cb5 100755 --- a/contracts/registry-tansu-manager/e2e-fast-tansu-testnet.sh +++ b/contracts/registry-tansu-manager/e2e-fast-tansu-testnet.sh @@ -7,27 +7,25 @@ # Custom Tansu (testnet): CDK7JBIIP6E75HOYLGRGWAHQLT6JUNUXQ7GNOYS3NAP26GISUXJ26UON # https://stellar.expert/explorer/testnet/contract/CDK7JBIIP6E75HOYLGRGWAHQLT6JUNUXQ7GNOYS3NAP26GISUXJ26UON # -# Flow (single phase): +# Flow (single phase, single `manager.trigger` tx for the publish): # 1. Register a fresh Tansu project with min_voting_period=$MIN_VOTING_PERIOD seconds # 2. Add maintainer + voter as Tansu members # 3. Upload hello.wasm, deploy registry, deploy manager, set_manager -# 4. Create proposal whose outcome targets `manager.publish_hash(...)` (a -# no-op proxy on the manager, same signature as the registry's). Tansu's -# auto-invocation in `contract_dao.rs::execute` lands on this no-op -# instead of the registry — pointing the outcome at the registry -# directly would fail at the registry's `manager.require_auth` (Tansu -# isn't in that auth chain) and `try_invoke_contract` would propagate -# the failure, reverting the whole Tansu tx. +# 4. Create proposal whose outcome is `registry.publish_hash(...)` +# targeting the registry directly. # 5. Vote Approve from the second account # 6. Sleep until past voting_ends_at + execute_delay -# 7. Tansu.execute (Active -> Approved). The no-op proxy succeeds; the -# proposal status persists. -# 8. manager.execute(proposal_id) — re-reads the now-Approved proposal, -# checks the outcome targets one of its proxies (and isn't a recursive -# `execute` re-entry), then forwards `oc.execute_fn + oc.args` to the -# registry with this contract's auth satisfying manager.require_auth. -# 9. Assert registry.fetch_hash returns the wasm hash we uploaded -# 10. Replay guard via second manager.execute +# 7. Call `manager.trigger(proposal_id, maintainer)`. The manager reads the +# proposal under its configured `(project_key, proposal_id)`, takes the +# single approved-branch outcome, and pre-authorizes that exact +# sub-call via `env.authorize_as_current_contract`. It then calls +# `Tansu.execute(maintainer, project_key, proposal_id, _, _)` which +# tallies votes, flips the proposal to Approved, and auto-invokes the +# outcome — the pre-authorization satisfies the registry's +# `manager.require_auth()` so publish_hash runs in the same tx. +# 8. Assert registry.fetch_hash returns the wasm hash we uploaded +# 9. Replay guard via second manager.trigger (Tansu rejects ProposalActive +# on the already-executed proposal, which trigger propagates) # # Env (all optional): # NETWORK Stellar network alias (default: testnet) @@ -157,18 +155,30 @@ echo "==> Installing manager contract on registry" invoke --id "$REGISTRY_ID" --source "$MAINTAINER_ID" --send=yes \ -- set_manager --new_manager "$MANAGER_ID" >/dev/null -# 5. Create proposal whose outcome targets registry.publish_hash(hello). +# 4b. Hand Tansu maintainership over to the manager. After this, the manager +# is the sole project maintainer — so when `trigger` calls Tansu.execute, +# Tansu's `maintainer.require_auth` is satisfied by contract-implicit +# auth (manager is the direct caller) and the recorder doesn't need to +# synthesize a non-root auth entry. +echo "==> Tansu.update_config — replace maintainers with [$MANAGER_ID]" +invoke --id "$TANSU_ID" --source "$MAINTAINER_ID" --send=yes \ + -- update_config \ + --maintainer "$MAINTAINER_ADDR" \ + --key "$PROJECT_KEY" \ + --maintainers "[\"$MANAGER_ID\"]" \ + --url "https://example.invalid/${PROJECT_NAME}" \ + --ipfs "QmExampleIpfs0000000000000000000000000000000000" >/dev/null + +# 5. Create proposal whose outcome targets registry.publish_hash directly. +# The manager pre-authorizes this specific outcome via +# `authorize_as_current_contract` inside `trigger`, then drives +# Tansu.execute itself — single tx, no non-root auth gymnastics. NOW=$(date +%s) -VOTING_ENDS_AT=$((NOW + MIN_VOTING_PERIOD + 15)) # +15s buffer for tx propagation +VOTING_ENDS_AT=$((NOW + MIN_VOTING_PERIOD + 15)) echo "==> Creating proposal (voting_ends_at=$VOTING_ENDS_AT, in ~$((VOTING_ENDS_AT-NOW))s)" -# Outcome targets the manager's no-op `publish_hash` proxy (same signature as -# the registry's). Tansu's auto-invocation lands there harmlessly so the -# proposal can flip to Approved; manager.execute(proposal_id) then re-reads -# the same outcome and forwards `publish_hash + args` to the registry with -# this contract's auth. OUTCOME=$(cat < Tansu.execute (Active -> Approved)" -STATUS_RAW=$(invoke --id "$TANSU_ID" --source "$MAINTAINER_ID" --send=yes \ - -- execute \ - --maintainer "$MAINTAINER_ADDR" \ - --project_key "$PROJECT_KEY" \ - --proposal_id "$PROPOSAL_ID") -STATUS="${STATUS_RAW//\"/}" -echo " status: $STATUS" -[[ "$STATUS" == "Approved" ]] || { echo "❌ proposal didn't pass: $STATUS" >&2; exit 1; } - -# 9. manager.execute -> registry.publish_hash via XCC. Manager re-reads the -# Approved proposal from Tansu, verifies the outcome targets one of its -# no-op proxies (not `execute` itself), and forwards execute_fn + args to -# the registry. -echo "==> manager.execute -> registry.publish_hash" +# 8. manager.trigger drives Tansu.execute + the publish in one tx. +echo "==> manager.trigger -> Tansu.execute -> registry.publish_hash (single tx)" invoke --id "$MANAGER_ID" --source "$MAINTAINER_ID" --send=yes \ - -- execute --proposal_id "$PROPOSAL_ID" >/dev/null + -- trigger --proposal_id "$PROPOSAL_ID" >/dev/null -# 10. Verify the publish landed. +# 9. Verify the publish landed. echo "==> Verifying registry has hello@$HELLO_VERSION -> $HELLO_HASH" PUBLISHED_HASH_RAW=$(invoke --id "$REGISTRY_ID" --source "$MAINTAINER_ID" \ -- fetch_hash --wasm_name hello --version "\"$HELLO_VERSION\"") @@ -244,12 +240,14 @@ else exit 1 fi -# 11. Replay guard. -echo "==> Replay check — second manager.execute must fail with AlreadyExecuted" +# 10. Replay guard — second manager.trigger calls into Tansu.execute again, +# which panics with ProposalActive (#402) because the proposal is no +# longer Active. +echo "==> Replay check — second manager.trigger must fail (ProposalActive)" REPLAY_OUT=$(invoke --id "$MANAGER_ID" --source "$MAINTAINER_ID" --send=yes \ - -- execute --proposal_id "$PROPOSAL_ID" 2>&1 || true) -if grep -qE 'AlreadyExecuted|Error\(Contract, ?#5\)' <<<"$REPLAY_OUT"; then - echo " ✓ replay rejected" + -- trigger --proposal_id "$PROPOSAL_ID" 2>&1 || true) +if grep -qE 'ProposalActive|Error\(Contract, ?#402\)' <<<"$REPLAY_OUT"; then + echo " ✓ replay rejected by Tansu" else echo " ❌ replay was NOT rejected" >&2 echo "$REPLAY_OUT" >&2 @@ -264,6 +262,6 @@ cat < $STATUS + proposal: #$PROPOSAL_ID -> Approved (via manager.trigger) hello: $HELLO_HASH @ $HELLO_VERSION EOF diff --git a/contracts/registry-tansu-manager/e2e-real-tansu-testnet.sh b/contracts/registry-tansu-manager/e2e-real-tansu-testnet.sh index b4fabf6..446000b 100755 --- a/contracts/registry-tansu-manager/e2e-real-tansu-testnet.sh +++ b/contracts/registry-tansu-manager/e2e-real-tansu-testnet.sh @@ -171,19 +171,30 @@ phase_setup() { invoke --id "$REGISTRY_ID" --source "$MAINTAINER_ID" --send=yes \ -- set_manager --new_manager "$MANAGER_ID" >/dev/null - # 7. Build the proposal. Outcome targets the manager's no-op `publish_hash` - # proxy (same signature as the registry's). Tansu auto-invokes outcomes - # inline; targeting the registry directly would fail at - # `manager.require_auth` because Tansu isn't in that auth chain — this - # manager is. The finalize phase then calls `manager.execute(proposal_id)` - # which re-reads the same outcome and forwards `execute_fn + args` to - # the registry with this contract's auth. + # 6b. Hand Tansu maintainership over to the manager. After this, when the + # finalize phase calls `manager.trigger(proposal_id)`, the manager is + # the direct caller of Tansu.execute, so Tansu's internal + # `maintainer.require_auth` is satisfied by contract-implicit auth + # (no auth entry needed, no non-root recording issue). + echo "==> Tansu.update_config — replace maintainers with [$MANAGER_ID]" + invoke --id "$TANSU_ID" --source "$MAINTAINER_ID" --send=yes \ + -- update_config \ + --maintainer "$MAINTAINER_ADDR" \ + --key "$PROJECT_KEY" \ + --maintainers "[\"$MANAGER_ID\"]" \ + --url "https://example.invalid/${PROJECT_NAME}" \ + --ipfs "QmExampleIpfs0000000000000000000000000000000000" >/dev/null + + # 7. Build the proposal. Outcome targets registry.publish_hash directly — + # the manager pre-authorizes this specific call via + # `authorize_as_current_contract` inside `trigger` so the registry's + # `manager.require_auth` is satisfied. NOW=$(date +%s) VOTING_ENDS_AT=$((NOW + 24*3600 + 600)) # 24h + 10min cushion PROPOSAL_TITLE="${PROPOSAL_TITLE:-Add hello@${HELLO_VERSION} to registry}" OUTCOME=$(cat < Tansu.execute (Active -> Approved)" - STATUS_RAW=$(invoke --id "$TANSU_ID" --source "$MAINTAINER_ID" --send=yes \ - -- execute \ - --maintainer "$MAINTAINER_ADDR" \ - --project_key "$PROJECT_KEY" \ - --proposal_id "$PROPOSAL_ID") - STATUS="${STATUS_RAW//\"/}" - echo " status: $STATUS" - if [[ "$STATUS" != "Approved" ]]; then - echo "❌ proposal did not pass — Tansu returned: $STATUS" >&2; exit 1 - fi - - # 2. Manager.execute reads the now-Approved proposal from Tansu via - # get_proposal, then forwards the outcome (registry.publish_hash) via XCC. - echo "==> manager.execute -> registry.publish_hash via XCC" + # 1. manager.trigger drives Tansu.execute + the publish in one tx. The + # manager (set as Tansu maintainer in setup step 6b) is the direct + # caller of Tansu.execute, satisfying Tansu's + # `maintainer.require_auth`. The manager pre-authorizes the registry + # publish via `authorize_as_current_contract`, satisfying the + # registry's `manager.require_auth`. Single tx. + echo "==> manager.trigger -> Tansu.execute -> registry.publish_hash (single tx)" invoke --id "$MANAGER_ID" --source "$MAINTAINER_ID" --send=yes \ - -- execute --proposal_id "$PROPOSAL_ID" >/dev/null + -- trigger --proposal_id "$PROPOSAL_ID" >/dev/null - # 3. Verify the registry now has hello@version pointing at our uploaded hash. + # 2. Verify the registry now has hello@version pointing at our uploaded hash. echo "==> Verifying registry has hello@$HELLO_VERSION -> $HELLO_HASH" PUBLISHED_HASH_RAW=$(invoke --id "$REGISTRY_ID" --source "$MAINTAINER_ID" \ -- fetch_hash --wasm_name hello --version "\"$HELLO_VERSION\"") @@ -316,12 +318,12 @@ phase_finalize() { exit 1 fi - # 4. Replay guard. - echo "==> Replay check — second manager.execute must fail with AlreadyExecuted" + # 3. Replay guard — Tansu's own ProposalActive check. + echo "==> Replay check — second manager.trigger must fail (ProposalActive)" REPLAY_OUT=$(invoke --id "$MANAGER_ID" --source "$MAINTAINER_ID" --send=yes \ - -- execute --proposal_id "$PROPOSAL_ID" 2>&1 || true) - if grep -qE 'AlreadyExecuted|Error\(Contract, ?#5\)' <<<"$REPLAY_OUT"; then - echo " ✓ replay rejected" + -- trigger --proposal_id "$PROPOSAL_ID" 2>&1 || true) + if grep -qE 'ProposalActive|Error\(Contract, ?#402\)' <<<"$REPLAY_OUT"; then + echo " ✓ replay rejected by Tansu" else echo " ❌ replay was NOT rejected" >&2 echo "$REPLAY_OUT" >&2 @@ -335,7 +337,7 @@ phase_finalize() { project: $PROJECT_NAME ($PROJECT_KEY) registry: $REGISTRY_ID manager: $MANAGER_ID - proposal: #$PROPOSAL_ID -> $STATUS + proposal: #$PROPOSAL_ID -> Approved (via manager.trigger) hello: $HELLO_HASH @ $HELLO_VERSION EOF } diff --git a/contracts/registry-tansu-manager/src/lib.rs b/contracts/registry-tansu-manager/src/lib.rs index 81e7ecf..c5dfaf4 100644 --- a/contracts/registry-tansu-manager/src/lib.rs +++ b/contracts/registry-tansu-manager/src/lib.rs @@ -1,40 +1,42 @@ #![no_std] - -use soroban_sdk::{self, contract, contractimpl, Address, Bytes, BytesN, Env, String, Symbol, Val}; -use soroban_sdk_tools::{contractstorage, InstanceItem, PersistentMap}; +// Tansu-stub-generated client + types include multi-arg fns (e.g. +// `set_deploy_proposal`), which trip `too_many_arguments`. Allow on the lib +// since the lint fires inside the macro expansion of `import_contract_client!`. +#![allow(clippy::too_many_arguments)] + +use soroban_sdk::{ + self, + auth::{ContractContext, InvokerContractAuthEntry, SubContractInvocation}, + contract, contractimpl, vec, Address, Bytes, Env, IntoVal, Symbol, Val, Vec, +}; +use soroban_sdk_tools::{contractstorage, InstanceItem}; #[soroban_sdk_tools::scerr] pub enum Error { - /// Proposal exists but is not in the `Approved` state. - NotApproved, - /// Proposal has no outcome contracts attached. + /// Proposal has no outcomes attached. NoOutcomeContracts, - /// Proposal has more than one outcome contract. + /// Proposal has more than one outcome — this manager authorizes exactly + /// one sub-call per proposal. MultipleOutcomes, - /// Proposal's outcome targets an address other than the configured registry. - OutcomeTargetMismatch, - /// Proposal has already been executed by this manager. - AlreadyExecuted, } -// Tansu proposal types + client come from the `tansu-stub` contract's wasm -// (built by `stellar scaffold build` ahead of this crate via the Cargo edge in -// `[dependencies]`). The stub is the single source of truth for these types, -// hand-mirrored from upstream Tansu — see `contracts/test/tansu-stub/src/lib.rs`. -// At runtime the manager points its `tansu` Address at *real* Tansu; the -// wire-level encoding matches because the stub mirrors Tansu's spec. +// Proposal/status types are derived from the tansu-stub contract's wasm spec. +// At runtime this manager points at *real* Tansu; the stub matches Tansu's +// wire format so the generated `get_proposal` client decodes a live proposal +// correctly. stellar_registry::import_contract_client!(tansu_stub); #[contractstorage(auto_shorten = true)] pub struct Storage { - /// Tansu DAO contract this manager queries proposals from. + /// Tansu DAO contract whose proposals this manager drives. tansu: InstanceItem
, - /// Tansu workspace key this manager represents. + /// Tansu workspace key this manager represents. All Tansu lookups are + /// keyed by this — a wrong-project caller can't piggyback. project_key: InstanceItem, - /// Registry contract this manager forwards approved outcomes to. + /// Registry this manager is the manager of. Recorded for inspection; + /// `trigger` doesn't read it directly because it uses whatever outcome + /// the (project_key-gated) proposal carries. registry: InstanceItem
, - /// Proposal IDs that have already been executed (replay guard). - executed: PersistentMap, } #[contract] @@ -60,45 +62,43 @@ impl RegistryTansuManager { Storage::get_registry(env).unwrap() } - /// Execute a passed Tansu proposal by forwarding its outcome to the registry. + /// Drive a Tansu proposal through to outcome execution in one transaction. /// - /// The proposal must be in `Approved` state and carry exactly one - /// `OutcomeContract`. The outcome's `address` must be this contract — i.e. - /// the proposal points at one of the no-op proxies on this manager (e.g. - /// [`publish_hash`]). Tansu's `execute` auto-invokes outcomes inline via - /// `env.try_invoke_contract`, so an outcome targeting the registry - /// directly would fail at the registry's `manager.require_auth()` (Tansu - /// isn't in that chain — this manager is) and revert the whole Tansu tx. - /// Routing through a no-op proxy here lets Tansu's auto-invocation succeed, - /// the proposal flip to `Approved`, and then an external caller invokes - /// `manager.execute(proposal_id)` to do the real registry forward with - /// this contract's auth. + /// Flow: /// - /// The forward itself is untyped — `oc.execute_fn` and `oc.args` are - /// passed through to the registry as-is. Any registry method we want to - /// gate behind a proposal just needs a matching no-op proxy added to this - /// contract; `execute` is not hardcoded to a specific method. + /// 1. Read the proposal from this manager's configured Tansu under this + /// manager's configured `project_key`. Wrong-project callers can't + /// construct a working invocation — `get_proposal` is keyed by + /// `(project_key, proposal_id)` Tansu-side, so any mismatched proposal + /// decodes to whatever lives at that key in *our* DAO or panics. + /// 2. Take the single approved-branch outcome (`outcome_contracts[0]`): + /// its `address`, `execute_fn`, and `args`. + /// 3. Pre-authorize **this contract's auth** for exactly that one + /// sub-call via `env.authorize_as_current_contract(...)`. Nothing + /// else gets authorized. The auth entry is scoped to one specific + /// `(contract, fn, args)` triple. + /// 4. Call `Tansu.execute(maintainer, project_key, proposal_id, _, _)`. + /// Tansu tallies the votes, sets the proposal to its terminal status, + /// and (on `Approved`) auto-invokes the outcome. When that outcome + /// reaches `manager.require_auth()`, the host matches it against the + /// pre-authorization from step 3 and lets the call run. /// - /// `execute` is rejected as a forward target to prevent recursive - /// re-entry through a maliciously crafted outcome. + /// For this to work the manager must be the Tansu project's maintainer + /// (set up at deploy time via `Tansu::register(..., maintainers=[manager])` + /// or `update_config`). That way the manager is the direct caller of + /// `Tansu::execute`, so Tansu's internal `maintainer.require_auth()` is + /// satisfied by contract-implicit auth — no auth entry needed for the + /// maintainer requirement, no non-root recording issue. /// - /// Replay-protected: a successful `execute` marks the proposal as - /// executed; later calls with the same `proposal_id` return - /// `AlreadyExecuted`. - pub fn execute(env: &Env, proposal_id: u32) -> Result { - if Storage::has_executed(env, &proposal_id) { - return Err(Error::AlreadyExecuted); - } + /// Tansu's own `if proposal.status != Active` guard inside `execute` + /// prevents the same proposal being triggered twice — no separate + /// replay guard needed here. + pub fn trigger(env: &Env, proposal_id: u32) -> Result<(), Error> { let tansu = Storage::get_tansu(env).unwrap(); let project_key = Storage::get_project_key(env).unwrap(); - let registry = Storage::get_registry(env).unwrap(); let proposal = tansu_stub::Client::new(env, &tansu).get_proposal(&project_key, &proposal_id); - - if !matches!(proposal.status, tansu_stub::ProposalStatus::Approved) { - return Err(Error::NotApproved); - } let outcomes = proposal .outcome_contracts .ok_or(Error::NoOutcomeContracts)?; @@ -106,45 +106,36 @@ impl RegistryTansuManager { return Err(Error::MultipleOutcomes); } let oc = outcomes.get(0).unwrap(); - if oc.address != env.current_contract_address() { - return Err(Error::OutcomeTargetMismatch); - } - if oc.execute_fn == Symbol::new(env, "execute") { - // Don't let a crafted outcome recurse back into us. - return Err(Error::OutcomeTargetMismatch); - } - Storage::set_executed(env, &proposal_id, &true); - let result: Val = env.invoke_contract(®istry, &oc.execute_fn, oc.args); - Ok(result) + env.authorize_as_current_contract(vec![ + env, + InvokerContractAuthEntry::Contract(SubContractInvocation { + context: ContractContext { + contract: oc.address.clone(), + fn_name: oc.execute_fn.clone(), + args: oc.args.clone(), + }, + sub_invocations: Vec::new(env), + }), + ]); + + // Tansu.execute(maintainer, project_key, proposal_id, tallies, seeds). + // maintainer = self — must match the project's `maintainers` list in + // Tansu (configured at registration / update_config time). + let _: Val = env.invoke_contract( + &tansu, + &Symbol::new(env, "execute"), + vec![ + env, + env.current_contract_address().into_val(env), + project_key.into_val(env), + proposal_id.into_val(env), + None::>.into_val(env), + None::>.into_val(env), + ], + ); + Ok(()) } - - // ----------------------------------------------------------------------- - // No-op proxy methods. - // - // Each one mirrors the signature of a registry method we want to gate - // behind a Tansu proposal. The proposal's outcome targets one of these by - // name + args; Tansu's auto-invocation lands here (does nothing); then - // `execute(proposal_id)` re-reads the same outcome and forwards - // `execute_fn + args` to the registry with this contract's auth chain. - // - // Adding support for another gated registry method = add another no-op - // proxy below with the matching signature. `execute` itself is unchanged. - // ----------------------------------------------------------------------- - - /// No-op proxy for `Registry::publish_hash`. - pub fn publish_hash( - _env: &Env, - _wasm_name: String, - _author: Address, - _wasm_hash: BytesN<32>, - _version: String, - ) { - } - - /// No-op proxy used by unit tests that exercise the forward path against - /// the `registry-stub` fixture's `manager_only(value: u32)` method. - pub fn manager_only(_env: &Env, _value: u32) {} } #[cfg(test)] diff --git a/contracts/registry-tansu-manager/src/test.rs b/contracts/registry-tansu-manager/src/test.rs index 317866f..1345800 100644 --- a/contracts/registry-tansu-manager/src/test.rs +++ b/contracts/registry-tansu-manager/src/test.rs @@ -1,328 +1,29 @@ -#![allow(clippy::needless_pass_by_value, clippy::should_panic_without_expect)] +#![allow(clippy::needless_pass_by_value)] extern crate std; -use soroban_sdk::{ - self, symbol_short, testutils::Address as _, vec, Address, Bytes, Env, IntoVal, String, Symbol, - Vec, -}; +use soroban_sdk::{testutils::Address as _, Address, Bytes, Env}; -use crate::{ - tansu_stub::{self, OutcomeContract, Proposal, ProposalStatus, Vote, VoteData}, - Error, RegistryTansuManager, RegistryTansuManagerClient, -}; +use crate::{RegistryTansuManager, RegistryTansuManagerClient}; -// The `registry-stub` contract is wasm-imported via the soroban-sdk-tools macro -// (not `import_contract_client!`) so we get the test-only `AuthClient` builder -// alongside the regular `Client`. The AuthClient lets the -// `registry_rejects_direct_caller` test below express "outsider tries to call -// manager_only" with a single chained call instead of constructing MockAuth -// scaffolding by hand. -mod registry_stub { - soroban_sdk_tools::contractimport!(file = "../../target/stellar/local/registry_stub.wasm"); +// Wasm-import the standalone tansu-stub so we can register it as the Tansu +// the manager queries during `__check_auth`. +mod tansu_stub_wasm { + soroban_sdk_tools::contractimport!(file = "../../target/stellar/local/tansu_stub.wasm"); } -// --------------------------------------------------------------------------- -// Test scaffolding -// --------------------------------------------------------------------------- - -struct Setup { - env: Env, - project_key: Bytes, - tansu: Address, - registry: Address, - #[allow(dead_code)] - manager: Address, - manager_client: RegistryTansuManagerClient<'static>, -} - -fn setup() -> Setup { +#[test] +fn constructor_stores_values() { let env = Env::default(); + let tansu = env.register(tansu_stub_wasm::WASM, ()); + let registry = Address::generate(&env); let project_key = Bytes::from_slice(&env, &[7u8; 16]); - let tansu = env.register(tansu_stub::WASM, ()); - - // Register the manager with a dummy registry address; the real address is - // patched into instance storage below once the registry-stub is registered. let manager = env.register( RegistryTansuManager, - (tansu.clone(), project_key.clone(), Address::generate(&env)), - ); - let registry = env.register(registry_stub::WASM, (manager.clone(),)); - - env.as_contract(&manager, || { - crate::Storage::set_registry(&env, ®istry); - }); - - let manager_client = RegistryTansuManagerClient::new(&env, &manager); - - Setup { - env, - project_key, - tansu, - registry, - manager, - manager_client, - } -} - -fn empty_vote_data(env: &Env) -> VoteData { - VoteData { - voting_ends_at: 0, - public_voting: true, - token_contract: None, - votes: Vec::::new(env), - } -} - -fn plant_proposal( - env: &Env, - tansu: &Address, - project_key: &Bytes, - id: u32, - status: ProposalStatus, - outcomes: Option>, -) { - let proposal = Proposal { - id, - title: String::from_str(env, "t"), - proposer: Address::generate(env), - ipfs: String::from_str(env, ""), - vote_data: empty_vote_data(env), - status, - outcome_contracts: outcomes, - }; - tansu_stub::Client::new(env, tansu).set_proposal(project_key, &proposal); -} - -fn one_outcome(env: &Env, manager: &Address, value: u32) -> Vec { - vec![ - env, - OutcomeContract { - address: manager.clone(), - execute_fn: symbol_short!("man_only"), - args: vec![env, value.into_val(env)], - }, - ] -} - -// --------------------------------------------------------------------------- -// Happy path: approved proposal -> registry call succeeds via contract auth -// --------------------------------------------------------------------------- - -#[test] -fn approved_proposal_forwards_to_registry() { - let s = setup(); - let outcomes = vec![ - &s.env, - OutcomeContract { - address: s.manager.clone(), - execute_fn: Symbol::new(&s.env, "manager_only"), - args: vec![&s.env, 42u32.into_val(&s.env)], - }, - ]; - plant_proposal( - &s.env, - &s.tansu, - &s.project_key, - 1, - ProposalStatus::Approved, - Some(outcomes), - ); - - // No external signer needed: the manager contract's auth satisfies - // registry's `manager.require_auth()` via the XCC contract-auth chain. - let result: u32 = s - .manager_client - .execute(&1) - .try_into() - .expect("Val should decode to u32"); - - assert_eq!(result, 42); - - let reg = registry_stub::Client::new(&s.env, &s.registry); - assert_eq!(reg.recorded(), Some(42)); -} - -// --------------------------------------------------------------------------- -// Negative cases -// --------------------------------------------------------------------------- - -#[test] -fn active_proposal_is_rejected() { - let s = setup(); - plant_proposal( - &s.env, - &s.tansu, - &s.project_key, - 1, - ProposalStatus::Active, - Some(one_outcome(&s.env, &s.registry, 1)), - ); - - let err = s.manager_client.try_execute(&1).err().unwrap().unwrap(); - assert_eq!(err, Error::NotApproved); -} - -#[test] -fn rejected_proposal_is_rejected() { - let s = setup(); - plant_proposal( - &s.env, - &s.tansu, - &s.project_key, - 1, - ProposalStatus::Rejected, - Some(one_outcome(&s.env, &s.registry, 1)), + (tansu.clone(), project_key.clone(), registry.clone()), ); - - let err = s.manager_client.try_execute(&1).err().unwrap().unwrap(); - assert_eq!(err, Error::NotApproved); -} - -#[test] -fn proposal_without_outcomes_is_rejected() { - let s = setup(); - plant_proposal( - &s.env, - &s.tansu, - &s.project_key, - 1, - ProposalStatus::Approved, - None, - ); - - let err = s.manager_client.try_execute(&1).err().unwrap().unwrap(); - assert_eq!(err, Error::NoOutcomeContracts); -} - -#[test] -fn proposal_with_multiple_outcomes_is_rejected() { - let s = setup(); - let outcomes = vec![ - &s.env, - OutcomeContract { - address: s.manager.clone(), - execute_fn: Symbol::new(&s.env, "manager_only"), - args: vec![&s.env, 1u32.into_val(&s.env)], - }, - OutcomeContract { - address: s.manager.clone(), - execute_fn: Symbol::new(&s.env, "manager_only"), - args: vec![&s.env, 2u32.into_val(&s.env)], - }, - ]; - plant_proposal( - &s.env, - &s.tansu, - &s.project_key, - 1, - ProposalStatus::Approved, - Some(outcomes), - ); - - let err = s.manager_client.try_execute(&1).err().unwrap().unwrap(); - assert_eq!(err, Error::MultipleOutcomes); -} - -#[test] -fn proposal_targeting_wrong_address_is_rejected() { - let s = setup(); - let wrong = Address::generate(&s.env); - let outcomes = vec![ - &s.env, - OutcomeContract { - address: wrong, - execute_fn: Symbol::new(&s.env, "manager_only"), - args: vec![&s.env, 1u32.into_val(&s.env)], - }, - ]; - plant_proposal( - &s.env, - &s.tansu, - &s.project_key, - 1, - ProposalStatus::Approved, - Some(outcomes), - ); - - let err = s.manager_client.try_execute(&1).err().unwrap().unwrap(); - assert_eq!(err, Error::OutcomeTargetMismatch); -} - -// --------------------------------------------------------------------------- -// Replay guard -// --------------------------------------------------------------------------- - -#[test] -fn approved_proposal_cannot_be_replayed() { - let s = setup(); - let outcomes = vec![ - &s.env, - OutcomeContract { - address: s.manager.clone(), - execute_fn: Symbol::new(&s.env, "manager_only"), - args: vec![&s.env, 7u32.into_val(&s.env)], - }, - ]; - plant_proposal( - &s.env, - &s.tansu, - &s.project_key, - 1, - ProposalStatus::Approved, - Some(outcomes), - ); - - s.manager_client.execute(&1); - let err = s.manager_client.try_execute(&1).err().unwrap().unwrap(); - assert_eq!(err, Error::AlreadyExecuted); -} - -#[test] -fn proposal_recursing_into_execute_is_rejected() { - // The new design *requires* outcome.address to be the manager itself (so - // Tansu's auto-invocation lands on a local no-op proxy instead of the - // registry). But the outcome.execute_fn must still NOT be `execute` — an - // attacker-crafted proposal could otherwise loop the manager back into - // itself. `execute` is rejected explicitly to break that. - let s = setup(); - let outcomes = vec![ - &s.env, - OutcomeContract { - address: s.manager.clone(), - execute_fn: Symbol::new(&s.env, "execute"), - args: vec![&s.env, 1u32.into_val(&s.env)], - }, - ]; - plant_proposal( - &s.env, - &s.tansu, - &s.project_key, - 1, - ProposalStatus::Approved, - Some(outcomes), - ); - - let err = s.manager_client.try_execute(&1).err().unwrap().unwrap(); - assert_eq!(err, Error::OutcomeTargetMismatch); -} - -// --------------------------------------------------------------------------- -// Auth-flow guard: the registry's manager-only function must reject calls -// that come from somewhere other than the manager contract. -// --------------------------------------------------------------------------- - -#[test] -#[should_panic] // require_auth on the manager address fails for an outside caller -fn registry_rejects_direct_caller() { - let s = setup(); - let outsider = Address::generate(&s.env); - - // Authorize the outsider (not the manager) and call manager_only directly. - // AuthClient chains the mock-auth setup onto the call in one builder, - // replacing the prior hand-built `setup_mock_auth(...)` + client.invoke(). - registry_stub::AuthClient::new(&s.env, &s.registry) - .manager_only(&99u32) - .authorize(&outsider) - .invoke(); + let client = RegistryTansuManagerClient::new(&env, &manager); + assert_eq!(client.tansu(), tansu); + assert_eq!(client.project_key(), project_key); + assert_eq!(client.registry(), registry); } From 24ea2d06f7b107e640b25bac5e76517f5a35712c Mon Sep 17 00:00:00 2001 From: Willem Wyndham Date: Thu, 28 May 2026 16:30:54 -0400 Subject: [PATCH 13/16] test: update e2e-testnet.sh + tansu-stub to drive trigger flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stub gains a Tansu-shaped `execute(maintainer, project_key, proposal_id, tallies, seeds) -> ProposalStatus` plus `Error::ProposalActive = 402` (matches Tansu's #402). It auto-invokes the proposal's index-0 outcome the same way real Tansu does, and sets an `Executed(project_key, proposal_id)` storage marker so a second call panics with the same #402 callers would see against live Tansu. This lets the stub-based smoke loop exercise the new `manager.trigger` path end-to-end without needing live testnet Tansu: manager.trigger(id) ├── reads proposal from stub.get_proposal under our project_key ├── env.authorize_as_current_contract(outcome) └── stub.execute(self, project_key, id, _, _) └── env.invoke_contract(outcome) -> registry.deploy(...) └── manager.require_auth -> matched by pre-auth -> hello deploys Verified by running e2e-testnet.sh against testnet — `hello(world)` returns "world" on the freshly deployed contract; second trigger rejects with #402. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../registry-tansu-manager/e2e-testnet.sh | 33 ++++++---- contracts/test/tansu-stub/src/lib.rs | 60 ++++++++++++++++++- 2 files changed, 79 insertions(+), 14 deletions(-) diff --git a/contracts/registry-tansu-manager/e2e-testnet.sh b/contracts/registry-tansu-manager/e2e-testnet.sh index a900556..f1c614e 100755 --- a/contracts/registry-tansu-manager/e2e-testnet.sh +++ b/contracts/registry-tansu-manager/e2e-testnet.sh @@ -5,13 +5,18 @@ # Flow: # 1. Deploy a fresh registry (no manager yet → author can self-publish). # 2. Author publishes hello.wasm to the registry. -# 3. Deploy a tansu-stub (stand-in for the Tansu DAO). +# 3. Deploy a tansu-stub (stand-in for the Tansu DAO; implements +# `get_proposal` + a Tansu-like `execute` that auto-invokes the outcome). # 4. Deploy the registry-tansu-manager, pointing at the stub + registry. # 5. Admin installs the manager on the registry. # 6. Plant an `Approved` deploy-proposal on the stub. -# 7. Call manager.execute(proposal_id) — registry deploys hello via XCC. +# 7. Call manager.trigger(proposal_id). The manager reads the proposal, +# pre-authorizes the outcome (registry.deploy) via +# `env.authorize_as_current_contract`, then calls stub.execute — which +# auto-invokes the outcome. The registry's manager.require_auth() is +# satisfied by the pre-authorization, so the deploy lands in one tx. # 8. Verify: invoke hello on the freshly deployed contract. -# 9. Replay guard: second execute(proposal_id) returns AlreadyExecuted. +# 9. Replay guard: second trigger(proposal_id) returns ProposalActive (#402). # # Usage: contracts/registry-tansu-manager/e2e-testnet.sh # Env vars: @@ -142,13 +147,16 @@ stellar contract invoke --id "$TANSU_ID" \ --contract_name "$CONTRACT_NAME" \ --admin "$ADMIN_ADDR" -# 7. Execute the proposal via the manager. No external signer is required — -# the registry's manager.require_auth() is satisfied by the manager -# contract's own outgoing-call auth. -echo "==> Executing proposal via manager" +# 7. Drive the proposal via manager.trigger. The manager reads the proposal +# from the stub, pre-authorizes the single outcome (registry.deploy) via +# `env.authorize_as_current_contract`, then calls the stub's +# `execute(...)`. The stub mimics real Tansu: auto-invokes the outcome via +# XCC; the registry's `manager.require_auth()` is satisfied by the +# pre-authorization, so the deploy lands in the same tx. +echo "==> Driving proposal via manager.trigger" stellar contract invoke --id "$MANAGER_ID" \ --source "$CALLER_ID" --network "$NETWORK" \ - -- execute --proposal_id "$PROPOSAL_ID" + -- trigger --proposal_id "$PROPOSAL_ID" # 8. Verify the registry now resolves the deployed contract. echo "==> Resolving deployed contract via registry" @@ -164,12 +172,13 @@ GREETING=$(stellar contract invoke --id "$DEPLOYED" \ -- hello --to world) echo " hello(world) = $GREETING" -# 9. Replay guard. -echo "==> Re-executing proposal — must fail with AlreadyExecuted" +# 9. Replay guard — Tansu's own `if proposal.status != Active { panic }` +# (mirrored by the stub as `Error::ProposalActive = 402`). +echo "==> Re-triggering proposal — must fail with ProposalActive" REPLAY_OUT=$(stellar contract invoke --id "$MANAGER_ID" \ --source "$CALLER_ID" --network "$NETWORK" \ - -- execute --proposal_id "$PROPOSAL_ID" 2>&1 || true) -if grep -qE 'AlreadyExecuted|Error\(Contract, ?#5\)' <<<"$REPLAY_OUT"; then + -- trigger --proposal_id "$PROPOSAL_ID" 2>&1 || true) +if grep -qE 'ProposalActive|Error\(Contract, ?#402\)' <<<"$REPLAY_OUT"; then echo " ✓ replay rejected" else echo " ❌ replay was NOT rejected" >&2 diff --git a/contracts/test/tansu-stub/src/lib.rs b/contracts/test/tansu-stub/src/lib.rs index 6352024..1eda911 100644 --- a/contracts/test/tansu-stub/src/lib.rs +++ b/contracts/test/tansu-stub/src/lib.rs @@ -2,8 +2,8 @@ #![allow(clippy::too_many_arguments, clippy::needless_pass_by_value)] use soroban_sdk::{ - contract, contractimpl, contracttype, vec, Address, Bytes, BytesN, Env, IntoVal, String, - Symbol, Val, Vec, + contract, contracterror, contractimpl, contracttype, panic_with_error, vec, Address, Bytes, + BytesN, Env, IntoVal, String, Symbol, Val, Vec, }; // Tansu Proposal types — kept in lock-step with both `Consulting-Manao/tansu` @@ -88,6 +88,22 @@ pub struct Proposal { #[contracttype] enum Key { Proposal(Bytes, u32), + /// Marker set when `execute(...)` runs for a (project_key, proposal_id). + /// On a second call we panic with [`Error::ProposalActive`] to mirror + /// Tansu's status guard. + Executed(Bytes, u32), +} + +/// Subset of `Consulting-Manao/tansu`'s `ContractErrors` that this stub +/// surfaces — keeps the same numeric codes so test harnesses can match by +/// `Error(Contract, #N)` the same way they would against real Tansu. +#[contracterror] +#[derive(Copy, Clone, Debug, Eq, PartialEq, PartialOrd, Ord)] +#[repr(u32)] +pub enum Error { + /// The proposal has already been executed once (Tansu would say the + /// proposal isn't `Active` anymore). + ProposalActive = 402, } #[contract] @@ -104,6 +120,46 @@ impl TansuStub { .unwrap() } + /// Stand-in for `Consulting-Manao/tansu`'s `execute`. Real Tansu tallies + /// votes, flips the proposal's status, then auto-invokes the + /// matching-branch outcome via `try_invoke_contract`. The stub skips the + /// tally (proposals are planted as `Approved` directly) but reproduces + /// the same auto-invocation + the `if proposal.status != Active` replay + /// guard, so callers like `RegistryTansuManager::trigger` can exercise + /// the full flow without needing live Tansu. + /// + /// `maintainer`, `tallies`, `seeds` are accepted for signature parity + /// with real Tansu's CLI shape; the stub ignores them. + pub fn execute( + env: &Env, + _maintainer: Address, + project_key: Bytes, + proposal_id: u32, + _tallies: Option>, + _seeds: Option>, + ) -> ProposalStatus { + let exec_key = Key::Executed(project_key.clone(), proposal_id); + if env.storage().persistent().has(&exec_key) { + panic_with_error!(env, Error::ProposalActive); + } + + let proposal: Proposal = env + .storage() + .persistent() + .get(&Key::Proposal(project_key, proposal_id)) + .unwrap(); + + // Auto-invoke the approved-branch outcome (index 0 in real Tansu). + if let Some(outcomes) = &proposal.outcome_contracts { + if let Some(oc) = outcomes.get(0) { + let _: Val = env.invoke_contract(&oc.address, &oc.execute_fn, oc.args.clone()); + } + } + + env.storage().persistent().set(&exec_key, &true); + proposal.status + } + /// Plant an arbitrary, fully-formed `Proposal`. Used by callers that need /// non-`Approved` states or unusual outcome shapes (e.g. unit tests that /// exercise every rejection path in `RegistryTansuManager::execute`). From 770783a7d65053a8a4fa13db4361f47659833942 Mon Sep 17 00:00:00 2001 From: Willem Wyndham Date: Tue, 2 Jun 2026 16:10:24 -0400 Subject: [PATCH 14/16] chore(registry-tansu-manager): wire into stellar-registry/contracts workspace Target-specific follow-up after replaying PR theahaco/scaffold-stellar#518's history into this repo. Pure additions on top of the moved 13 commits: - Cargo.toml: add contracts/registry-tansu-manager to workspace members (tansu-stub is already matched by contracts/test/*). - registry-tansu-manager/Cargo.toml: inherit repository from [workspace.package] instead of pointing at the scaffold-stellar tree. - .gitignore: ignore e2e-real-tansu-state-*.env sidecar files. - justfile: build via `stellar scaffold build` (not plain `stellar contract build`) so wasm is staged to target/stellar//, which the registry tests' `contractimport!` and the manager's `import_contract_client!(tansu_stub)` both resolve against. Pin STELLAR_NETWORK=local to match the target/stellar/local/ paths the tests import from, and binstall stellar-scaffold-cli in `setup`. - e2e scripts: drop the `hello` example payload (not moved); publish/deploy the registry contract's own wasm instead. e2e-testnet now deploys a subregistry through the proposal (registry's 3-arg __constructor) and verifies via a read-only manager() call; tansu-stub's set_deploy_proposal builds that constructor shape. e2e-fast/e2e-real publish the registry wasm under the name `registry`. - tansu-stub: satisfy this repo's stricter pedantic clippy (doc backticks, used_underscore_binding allow for macro-generated dispatch). Co-Authored-By: Claude Opus 4.8 --- .gitignore | 3 + Cargo.lock | 17 ++++ Cargo.toml | 1 + contracts/registry-tansu-manager/Cargo.toml | 2 +- .../e2e-fast-tansu-testnet.sh | 37 ++++---- .../e2e-real-tansu-testnet.sh | 43 +++++----- .../registry-tansu-manager/e2e-testnet.sh | 86 +++++++++++-------- contracts/test/tansu-stub/src/lib.rs | 28 ++++-- justfile | 15 +++- 9 files changed, 147 insertions(+), 85 deletions(-) diff --git a/.gitignore b/.gitignore index 454e38c..61e79a8 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,6 @@ test_snapshots/ .vscode/ .idea/ .DS_Store + +# Per-run state from e2e-real-tansu-testnet.sh setup +contracts/registry-tansu-manager/e2e-real-tansu-state-*.env diff --git a/Cargo.lock b/Cargo.lock index 41178ac..27ae80e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1253,6 +1253,16 @@ dependencies = [ "stellar-xdr 26.0.0", ] +[[package]] +name = "registry-tansu-manager" +version = "0.1.0" +dependencies = [ + "soroban-sdk", + "soroban-sdk-tools", + "stellar-registry", + "tansu-stub", +] + [[package]] name = "rfc6979" version = "0.4.0" @@ -1878,6 +1888,13 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "tansu-stub" +version = "0.0.0" +dependencies = [ + "soroban-sdk", +] + [[package]] name = "thiserror" version = "1.0.69" diff --git a/Cargo.toml b/Cargo.toml index 7e25e5a..a7ae52a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,6 +2,7 @@ resolver = "2" members = [ "contracts/registry", + "contracts/registry-tansu-manager", "contracts/test/*", ] diff --git a/contracts/registry-tansu-manager/Cargo.toml b/contracts/registry-tansu-manager/Cargo.toml index d90d79b..e4e046c 100644 --- a/contracts/registry-tansu-manager/Cargo.toml +++ b/contracts/registry-tansu-manager/Cargo.toml @@ -5,7 +5,7 @@ version = "0.1.0" license = "Apache-2.0" edition = "2021" publish = false -repository = "https://github.com/theahaco/scaffold-stellar/tree/main/contracts/registry-tansu-manager" +repository.workspace = true [lib] crate-type = ["cdylib"] diff --git a/contracts/registry-tansu-manager/e2e-fast-tansu-testnet.sh b/contracts/registry-tansu-manager/e2e-fast-tansu-testnet.sh index 9d22cb5..c5b3b01 100755 --- a/contracts/registry-tansu-manager/e2e-fast-tansu-testnet.sh +++ b/contracts/registry-tansu-manager/e2e-fast-tansu-testnet.sh @@ -10,7 +10,7 @@ # Flow (single phase, single `manager.trigger` tx for the publish): # 1. Register a fresh Tansu project with min_voting_period=$MIN_VOTING_PERIOD seconds # 2. Add maintainer + voter as Tansu members -# 3. Upload hello.wasm, deploy registry, deploy manager, set_manager +# 3. Upload registry.wasm (payload), deploy registry, deploy manager, set_manager # 4. Create proposal whose outcome is `registry.publish_hash(...)` # targeting the registry directly. # 5. Vote Approve from the second account @@ -44,14 +44,15 @@ MIN_VOTING_PERIOD="${MIN_VOTING_PERIOD:-60}" # Seconds between voting_ends_at and when Tansu.execute is callable. The custom # Tansu rejects 0 (InvalidVotingPeriod / #212) — any positive value is fine. EXECUTE_DELAY="${EXECUTE_DELAY:-60}" -HELLO_VERSION="${HELLO_VERSION:-0.1.0}" +PAYLOAD_VERSION="${PAYLOAD_VERSION:-0.1.0}" RUN_ID="${RUN_ID:-$(date +%s)}" -HELLO_WASM="$WASM_DIR/hello.wasm" +# Payload published to the registry is the registry wasm itself. REGISTRY_WASM="$WASM_DIR/registry.wasm" +PAYLOAD_WASM="$REGISTRY_WASM" MANAGER_WASM="$WASM_DIR/registry_tansu_manager.wasm" -for w in "$HELLO_WASM" "$REGISTRY_WASM" "$MANAGER_WASM"; do +for w in "$REGISTRY_WASM" "$MANAGER_WASM"; do [[ -f "$w" ]] || { echo "❌ missing $w — run \`just build\` first" >&2; exit 1; } done @@ -127,11 +128,11 @@ for who in "$MAINTAINER_ID:$MAINTAINER_ADDR:maintainer" "$VOTER_ID:$VOTER_ADDR:v fi done -# 3. Upload hello.wasm. -echo "==> Uploading hello.wasm" -HELLO_HASH=$(stellar contract upload --wasm "$HELLO_WASM" \ +# 3. Upload registry.wasm (payload). +echo "==> Uploading registry.wasm (payload)" +PAYLOAD_HASH=$(stellar contract upload --wasm "$PAYLOAD_WASM" \ --source "$MAINTAINER_ID" --network "$NETWORK") -echo " hash: $HELLO_HASH" +echo " hash: $PAYLOAD_HASH" # 4. Deploy registry (admin=manager=$MAINTAINER initially) and the manager contract. echo "==> Deploying registry" @@ -181,10 +182,10 @@ OUTCOME=$(cat </dev/null # 9. Verify the publish landed. -echo "==> Verifying registry has hello@$HELLO_VERSION -> $HELLO_HASH" +echo "==> Verifying registry has registry@$PAYLOAD_VERSION -> $PAYLOAD_HASH" PUBLISHED_HASH_RAW=$(invoke --id "$REGISTRY_ID" --source "$MAINTAINER_ID" \ - -- fetch_hash --wasm_name hello --version "\"$HELLO_VERSION\"") + -- fetch_hash --wasm_name registry --version "\"$PAYLOAD_VERSION\"") PUBLISHED_HASH="${PUBLISHED_HASH_RAW//\"/}" -if [[ "$PUBLISHED_HASH" == "$HELLO_HASH" ]]; then - echo " ✓ registry resolved hello@$HELLO_VERSION -> $PUBLISHED_HASH" +if [[ "$PUBLISHED_HASH" == "$PAYLOAD_HASH" ]]; then + echo " ✓ registry resolved registry@$PAYLOAD_VERSION -> $PUBLISHED_HASH" else - echo " ❌ registry returned $PUBLISHED_HASH, expected $HELLO_HASH" >&2 + echo " ❌ registry returned $PUBLISHED_HASH, expected $PAYLOAD_HASH" >&2 exit 1 fi @@ -263,5 +264,5 @@ cat < Approved (via manager.trigger) - hello: $HELLO_HASH @ $HELLO_VERSION + payload: $PAYLOAD_HASH @ $PAYLOAD_VERSION EOF diff --git a/contracts/registry-tansu-manager/e2e-real-tansu-testnet.sh b/contracts/registry-tansu-manager/e2e-real-tansu-testnet.sh index 446000b..84481a4 100755 --- a/contracts/registry-tansu-manager/e2e-real-tansu-testnet.sh +++ b/contracts/registry-tansu-manager/e2e-real-tansu-testnet.sh @@ -7,7 +7,7 @@ # # $ ./e2e-real-tansu-testnet.sh setup # -> registers a fresh Tansu project, deploys registry + manager, -# uploads hello.wasm, creates a publish_hash proposal on Tansu, +# uploads registry.wasm (payload), creates a publish_hash proposal on Tansu, # votes yes, saves state to a sidecar file. # -> prints the exact follow-up command + timestamp. # @@ -32,8 +32,9 @@ WASM_DIR="$REPO_ROOT/target/stellar/local" NETWORK="${NETWORK:-testnet}" TANSU_ID="${TANSU_ID:-CBXKUSLQPVF35FYURR5C42BPYA5UOVDXX2ELKIM2CAJMCI6HXG2BHGZA}" -HELLO_WASM="$WASM_DIR/hello.wasm" +# Payload published to the registry is the registry wasm itself. REGISTRY_WASM="$WASM_DIR/registry.wasm" +PAYLOAD_WASM="$REGISTRY_WASM" MANAGER_WASM="$WASM_DIR/registry_tansu_manager.wasm" usage() { @@ -78,13 +79,13 @@ invoke() { stellar contract invoke --network "$NETWORK" "$@"; } # --------------------------------------------------------------------------- phase_setup() { require_network - for w in "$HELLO_WASM" "$REGISTRY_WASM" "$MANAGER_WASM"; do + for w in "$REGISTRY_WASM" "$MANAGER_WASM"; do [[ -f "$w" ]] || { echo "❌ missing $w — run \`just build\` first" >&2; exit 1; } done RUN_ID="${RUN_ID:-$(date +%s)}" STATE_FILE="$SCRIPT_DIR/e2e-real-tansu-state-${RUN_ID}.env" - HELLO_VERSION="${HELLO_VERSION:-0.1.0}" + PAYLOAD_VERSION="${PAYLOAD_VERSION:-0.1.0}" # Tansu enforces project name ≤ 15 chars. The name is also registered on # SorobanDomain under TLD .xlm, whose `validate_domain` requires bytes in # `[a-z]` only — no digits, no hyphens, no uppercase. Map run-id digits to @@ -138,11 +139,11 @@ phase_setup() { --member_address "$VOTER_ADDR" \ --meta "tansu-e2e voter" >/dev/null - # 3. Upload hello.wasm to get the hash the proposal will register. - echo "==> Uploading hello.wasm" - HELLO_HASH=$(stellar contract upload --wasm "$HELLO_WASM" \ + # 3. Upload registry.wasm (payload) to get the hash the proposal will register. + echo "==> Uploading registry.wasm (payload)" + PAYLOAD_HASH=$(stellar contract upload --wasm "$PAYLOAD_WASM" \ --source "$MAINTAINER_ID" --network "$NETWORK") - echo " hash: $HELLO_HASH" + echo " hash: $PAYLOAD_HASH" # 4. Deploy a fresh registry — admin & manager both set to the G account # initially, so we can swap the manager to the manager contract before @@ -191,16 +192,16 @@ phase_setup() { # `manager.require_auth` is satisfied. NOW=$(date +%s) VOTING_ENDS_AT=$((NOW + 24*3600 + 600)) # 24h + 10min cushion - PROPOSAL_TITLE="${PROPOSAL_TITLE:-Add hello@${HELLO_VERSION} to registry}" + PROPOSAL_TITLE="${PROPOSAL_TITLE:-Add registry@${PAYLOAD_VERSION} to registry}" OUTCOME=$(cat </dev/null - # 2. Verify the registry now has hello@version pointing at our uploaded hash. - echo "==> Verifying registry has hello@$HELLO_VERSION -> $HELLO_HASH" + # 2. Verify the registry now has registry@version pointing at our uploaded hash. + echo "==> Verifying registry has registry@$PAYLOAD_VERSION -> $PAYLOAD_HASH" PUBLISHED_HASH_RAW=$(invoke --id "$REGISTRY_ID" --source "$MAINTAINER_ID" \ - -- fetch_hash --wasm_name hello --version "\"$HELLO_VERSION\"") + -- fetch_hash --wasm_name registry --version "\"$PAYLOAD_VERSION\"") PUBLISHED_HASH="${PUBLISHED_HASH_RAW//\"/}" - if [[ "$PUBLISHED_HASH" == "$HELLO_HASH" ]]; then - echo " ✓ registry resolved hello@$HELLO_VERSION -> $PUBLISHED_HASH" + if [[ "$PUBLISHED_HASH" == "$PAYLOAD_HASH" ]]; then + echo " ✓ registry resolved registry@$PAYLOAD_VERSION -> $PUBLISHED_HASH" else - echo " ❌ registry returned $PUBLISHED_HASH, expected $HELLO_HASH" >&2 + echo " ❌ registry returned $PUBLISHED_HASH, expected $PAYLOAD_HASH" >&2 exit 1 fi @@ -338,7 +339,7 @@ phase_finalize() { registry: $REGISTRY_ID manager: $MANAGER_ID proposal: #$PROPOSAL_ID -> Approved (via manager.trigger) - hello: $HELLO_HASH @ $HELLO_VERSION + payload: $PAYLOAD_HASH @ $PAYLOAD_VERSION EOF } diff --git a/contracts/registry-tansu-manager/e2e-testnet.sh b/contracts/registry-tansu-manager/e2e-testnet.sh index f1c614e..5d3aa91 100755 --- a/contracts/registry-tansu-manager/e2e-testnet.sh +++ b/contracts/registry-tansu-manager/e2e-testnet.sh @@ -2,29 +2,35 @@ # End-to-end test of the registry-tansu-manager flow against testnet # (or any configured stellar network via $NETWORK). # +# The published + deployed payload is the registry contract's own wasm: the +# proposal deploys a *subregistry* (root = the root registry from step 1), which +# exercises the manager→registry deploy path against the registry's real +# 3-arg `__constructor(admin, manager, root)`. +# # Flow: -# 1. Deploy a fresh registry (no manager yet → author can self-publish). -# 2. Author publishes hello.wasm to the registry. +# 1. Deploy a fresh root registry (admin as bootstrap manager). +# 2. Publish the registry wasm to that registry under the name `registry`. # 3. Deploy a tansu-stub (stand-in for the Tansu DAO; implements # `get_proposal` + a Tansu-like `execute` that auto-invokes the outcome). # 4. Deploy the registry-tansu-manager, pointing at the stub + registry. # 5. Admin installs the manager on the registry. -# 6. Plant an `Approved` deploy-proposal on the stub. +# 6. Plant an `Approved` deploy-proposal on the stub (deploys a subregistry). # 7. Call manager.trigger(proposal_id). The manager reads the proposal, # pre-authorizes the outcome (registry.deploy) via # `env.authorize_as_current_contract`, then calls stub.execute — which # auto-invokes the outcome. The registry's manager.require_auth() is # satisfied by the pre-authorization, so the deploy lands in one tx. -# 8. Verify: invoke hello on the freshly deployed contract. +# 8. Verify: the registry resolves the deployed subregistry and it responds +# to a read call (`manager()`). # 9. Replay guard: second trigger(proposal_id) returns ProposalActive (#402). # # Usage: contracts/registry-tansu-manager/e2e-testnet.sh # Env vars: -# NETWORK Stellar network alias (default: testnet; must be in `stellar network ls`). -# RUN_ID Suffix appended to ephemeral identities/aliases (default: epoch). -# PROPOSAL_ID Proposal id to use (default: 1). -# HELLO_VERSION Version published for hello (default: 0.1.0). -# CONTRACT_NAME Name used when the registry deploys hello (default: hello-$RUN_ID). +# NETWORK Stellar network alias (default: testnet; must be in `stellar network ls`). +# RUN_ID Suffix appended to ephemeral identities/aliases (default: epoch). +# PROPOSAL_ID Proposal id to use (default: 1). +# PAYLOAD_VERSION Version published for the registry payload (default: 0.1.0). +# CONTRACT_NAME Name the registry gives the deployed subregistry (default: subregistry-$RUN_ID). set -euo pipefail @@ -35,18 +41,19 @@ WASM_DIR="$REPO_ROOT/target/stellar/local" NETWORK="${NETWORK:-testnet}" RUN_ID="${RUN_ID:-$(date +%s)}" PROPOSAL_ID="${PROPOSAL_ID:-1}" -HELLO_VERSION="${HELLO_VERSION:-0.1.0}" -CONTRACT_NAME="${CONTRACT_NAME:-hello-${RUN_ID}}" +PAYLOAD_VERSION="${PAYLOAD_VERSION:-0.1.0}" +CONTRACT_NAME="${CONTRACT_NAME:-subregistry-${RUN_ID}}" # 32-byte arbitrary project_key, hex-encoded. Tansu uses keccak256(name); we # just need a stable 32-byte value the manager can store and the stub can key on. PROJECT_KEY="aabbccddeeff00112233445566778899aabbccddeeff00112233445566778899" -HELLO_WASM="$WASM_DIR/hello.wasm" +# The registry wasm is both the registry we stand up (step 1) and the payload +# the proposal publishes + deploys as a subregistry (steps 2, 7). REGISTRY_WASM="$WASM_DIR/registry.wasm" MANAGER_WASM="$WASM_DIR/registry_tansu_manager.wasm" STUB_WASM="$WASM_DIR/tansu_stub.wasm" -for w in "$HELLO_WASM" "$REGISTRY_WASM" "$MANAGER_WASM" "$STUB_WASM"; do +for w in "$REGISTRY_WASM" "$MANAGER_WASM" "$STUB_WASM"; do if [ ! -f "$w" ]; then echo "❌ missing $w — run \`just build\` first" >&2 exit 1 @@ -92,23 +99,23 @@ REGISTRY_ID=$(stellar contract deploy --wasm "$REGISTRY_WASM" \ -- --admin "$ADMIN_ADDR" --manager "\"$ADMIN_ADDR\"") echo " registry: $REGISTRY_ID" -# 2. Upload hello's wasm and have admin-as-manager publish it on the author's -# behalf. With a manager set, the registry requires manager auth for the -# first publish under a given wasm name; the recorded author is still -# $AUTHOR_ADDR. -echo "==> Uploading hello.wasm" -HELLO_HASH=$(stellar contract upload --wasm "$HELLO_WASM" \ +# 2. Upload the registry wasm and have admin-as-manager publish it on the +# author's behalf under the name `registry`. With a manager set, the registry +# requires manager auth for the first publish under a given wasm name; the +# recorded author is still $AUTHOR_ADDR. +echo "==> Uploading registry.wasm (payload)" +PAYLOAD_HASH=$(stellar contract upload --wasm "$REGISTRY_WASM" \ --source "$ADMIN_ID" --network "$NETWORK") -echo " hash: $HELLO_HASH" +echo " hash: $PAYLOAD_HASH" -echo "==> Publishing hello@$HELLO_VERSION (author=$AUTHOR_ADDR, manager=$ADMIN_ID)" +echo "==> Publishing registry@$PAYLOAD_VERSION (author=$AUTHOR_ADDR, manager=$ADMIN_ID)" stellar contract invoke --id "$REGISTRY_ID" \ --source "$ADMIN_ID" --network "$NETWORK" \ -- publish_hash \ - --wasm_name hello \ + --wasm_name registry \ --author "$AUTHOR_ADDR" \ - --wasm_hash "$HELLO_HASH" \ - --version "$HELLO_VERSION" + --wasm_hash "$PAYLOAD_HASH" \ + --version "$PAYLOAD_VERSION" # 3. Tansu stub. echo "==> Deploying tansu-stub" @@ -134,7 +141,10 @@ stellar contract invoke --id "$REGISTRY_ID" \ --source "$ADMIN_ID" --network "$NETWORK" \ -- set_manager --new_manager "$MANAGER_ID" -# 6. Plant an Approved deploy-proposal on the stub. +# 6. Plant an Approved deploy-proposal on the stub. The outcome deploys a +# subregistry: init = registry __constructor(admin, manager=None, +# root=$REGISTRY_ID). `--manager` is omitted (None) so the deployed instance +# defers to $REGISTRY_ID as root rather than auto-deploying `unverified`. echo "==> Planting Approved deploy-proposal #$PROPOSAL_ID for contract '$CONTRACT_NAME'" stellar contract invoke --id "$TANSU_ID" \ --source "$ADMIN_ID" --network "$NETWORK" \ @@ -142,10 +152,11 @@ stellar contract invoke --id "$TANSU_ID" \ --project_key "$PROJECT_KEY" \ --proposal_id "$PROPOSAL_ID" \ --registry "$REGISTRY_ID" \ - --wasm_name "hello" \ - --version "\"$HELLO_VERSION\"" \ + --wasm_name "registry" \ + --version "\"$PAYLOAD_VERSION\"" \ --contract_name "$CONTRACT_NAME" \ - --admin "$ADMIN_ADDR" + --admin "$ADMIN_ADDR" \ + --root "$REGISTRY_ID" # 7. Drive the proposal via manager.trigger. The manager reads the proposal # from the stub, pre-authorizes the single outcome (registry.deploy) via @@ -166,11 +177,14 @@ DEPLOYED_RAW=$(stellar contract invoke --id "$REGISTRY_ID" \ DEPLOYED="${DEPLOYED_RAW//\"/}" echo " deployed: $DEPLOYED" -echo "==> Calling hello on the deployed contract" -GREETING=$(stellar contract invoke --id "$DEPLOYED" \ +# The deployed payload is a registry, not hello — prove it's live with a +# read-only `manager()` call (a subregistry deployed with manager=None returns +# null). +echo "==> Calling manager() on the deployed subregistry" +DEPLOYED_MANAGER=$(stellar contract invoke --id "$DEPLOYED" \ --source "$CALLER_ID" --network "$NETWORK" \ - -- hello --to world) -echo " hello(world) = $GREETING" + -- manager) +echo " manager() = $DEPLOYED_MANAGER" # 9. Replay guard — Tansu's own `if proposal.status != Active { panic }` # (mirrored by the stub as `Error::ProposalActive = 402`). @@ -190,8 +204,8 @@ fi cat <)` (and `manager = None`) to deploy a + /// subregistry; omitting both would make the deployed instance a root + /// registry, which requires a manager and auto-deploys `unverified`. + /// Use [`set_proposal_outcome`] for any other constructor shape. pub fn set_deploy_proposal( env: &Env, project_key: Bytes, @@ -184,9 +193,16 @@ impl TansuStub { version: Option, contract_name: String, admin: Address, + manager: Option
, + root: Option
, deployer: Option
, ) { - let init: Option> = Some(vec![env, admin.clone().into_val(env)]); + let init: Option> = Some(vec![ + env, + admin.clone().into_val(env), + manager.into_val(env), + root.into_val(env), + ]); let args: Vec = vec![ env, wasm_name.into_val(env), diff --git a/justfile b/justfile index 8a9e359..7c066b6 100644 --- a/justfile +++ b/justfile @@ -3,6 +3,9 @@ set dotenv-load := true export PATH := './target/bin:' + env_var('PATH') export CONFIG_DIR := 'target/' export CI_BUILD := env_var_or_default('CI_BUILD', '') +# Stage built wasm under target/stellar/local/ — the path the registry tests' +# `contractimport!` and the manager's `import_contract_client!` resolve against. +export STELLAR_NETWORK := env_var_or_default('STELLAR_NETWORK', 'local') [private] path: @@ -17,14 +20,20 @@ stellar +args: build_contract p: stellar contract build --profile contracts --package {{ p }} -# Build all contracts with the size-optimized profile +# Build all contracts with the size-optimized profile. Uses `stellar scaffold +# build` (not plain `stellar contract build`) so wasm is staged to +# target/stellar//, which the registry tests' `contractimport!` and +# registry-tansu-manager's `import_contract_client!(tansu_stub)` both resolve +# against. STELLAR_NETWORK defaults to `local` (see stellar-build), matching the +# `target/stellar/local/...` paths the tests import from. build: - stellar contract build --profile contracts + stellar scaffold build --profile contracts -# Setup git hooks and pin the stellar-cli version +# Setup git hooks and pin the CLI versions setup: git config core.hooksPath .githooks -cargo binstall -y stellar-cli --version 26.0.0 --force --install-path ./target/bin + -cargo binstall -y stellar-scaffold-cli --version 0.0.24 --force --install-path ./target/bin # Tests import compiled fixture wasm via `contractimport!`, so build first test: build From 090ac57b0f14b5bef8ffd6dcf46df5d9d19c80c4 Mon Sep 17 00:00:00 2001 From: Willem Wyndham Date: Tue, 2 Jun 2026 16:25:32 -0400 Subject: [PATCH 15/16] ci: install stellar-scaffold + nextest so `just build`/`just test` run MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `just build` now invokes `stellar scaffold build`, which needs the `stellar-scaffold` plugin on PATH — CI only installed `stellar-cli`. Binstall `stellar-scaffold-cli@0.0.24` (prebuilt, no compile) in both rust.yml and tests.yml after the stellar-cli step. Also add `taiki-e/install-action@nextest` to tests.yml: `just test` runs `cargo t` (= `nextest run` per .cargo/config.toml) but tests.yml never installed nextest, so the job had been failing with exit 101 on main too. Co-Authored-By: Claude Opus 4.8 --- .github/workflows/rust.yml | 2 ++ .github/workflows/tests.yml | 4 ++++ 2 files changed, 6 insertions(+) diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index e18520c..28ee5ee 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -40,6 +40,8 @@ jobs: - name: Run cargo fmt run: cargo fmt --all -- --check - uses: stellar/stellar-cli@v26.0.0 + - name: Install stellar-scaffold (just build uses `stellar scaffold build`) + run: cargo binstall -y stellar-scaffold-cli@0.0.24 - name: build since clippy needs contracts to be built run: just build - name: Run cargo clippy diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 13f6208..32d24df 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -30,7 +30,11 @@ jobs: - run: rustup update - run: rustup target add wasm32v1-none - uses: taiki-e/install-action@just + - uses: taiki-e/install-action@nextest + - uses: cargo-bins/cargo-binstall@main - uses: stellar/stellar-cli@v26.0.0 + - name: Install stellar-scaffold (just build uses `stellar scaffold build`) + run: cargo binstall -y stellar-scaffold-cli@0.0.24 - uses: mozilla-actions/sccache-action@v0.0.10 # `just test` builds the contracts (so the test wasms exist for # `contractimport!`) and then runs the tests, which execute in the From 37c2511b27a2765924328b005ff8b46068dec22d Mon Sep 17 00:00:00 2001 From: Willem Wyndham Date: Tue, 2 Jun 2026 20:56:25 -0400 Subject: [PATCH 16/16] test(e2e): generate Tansu project names from a readable prefix + random tag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the opaque epoch-digit→a-j mapping (which produced names like `ffbhiaedcehb`) with a readable prefix plus a random lowercase suffix: `fast` / `real` (8 random [a-z], 12 chars total). Stays within Tansu's SorobanDomain constraint (≤15 chars, lowercase [a-z] only — no digits, so the prefix can't be `e2e*`), and the random tag avoids "name taken" collisions on re-runs. e2e-real still persists the chosen name to its state file so `finalize` reuses it. Co-Authored-By: Claude Opus 4.8 --- .../registry-tansu-manager/e2e-fast-tansu-testnet.sh | 8 +++++--- .../registry-tansu-manager/e2e-real-tansu-testnet.sh | 10 ++++++---- 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/contracts/registry-tansu-manager/e2e-fast-tansu-testnet.sh b/contracts/registry-tansu-manager/e2e-fast-tansu-testnet.sh index c5b3b01..3a10594 100755 --- a/contracts/registry-tansu-manager/e2e-fast-tansu-testnet.sh +++ b/contracts/registry-tansu-manager/e2e-fast-tansu-testnet.sh @@ -77,9 +77,11 @@ ensure_account "$VOTER_ID" MAINTAINER_ADDR=$(stellar keys address "$MAINTAINER_ID") VOTER_ADDR=$(stellar keys address "$VOTER_ID") -# Tansu name validation (SorobanDomain): ≤15 chars, [a-z] only. Map run-id digits → a-j. -short_id=$(printf '%s' "$RUN_ID" | tr '0-9' 'a-j') -PROJECT_NAME="${PROJECT_NAME:-ff${short_id: -10}}" +# Tansu name validation (SorobanDomain): ≤15 chars, lowercase [a-z] only — no +# digits or hyphens. Readable prefix + a random lowercase tag for uniqueness. +# (`|| true` swallows the SIGPIPE `head` raises on `tr` under `set -o pipefail`.) +rand=$(LC_ALL=C tr -dc 'a-z' Network: $NETWORK" echo "==> Tansu (custom): $TANSU_ID" diff --git a/contracts/registry-tansu-manager/e2e-real-tansu-testnet.sh b/contracts/registry-tansu-manager/e2e-real-tansu-testnet.sh index 84481a4..2dcc798 100755 --- a/contracts/registry-tansu-manager/e2e-real-tansu-testnet.sh +++ b/contracts/registry-tansu-manager/e2e-real-tansu-testnet.sh @@ -88,11 +88,13 @@ phase_setup() { PAYLOAD_VERSION="${PAYLOAD_VERSION:-0.1.0}" # Tansu enforces project name ≤ 15 chars. The name is also registered on # SorobanDomain under TLD .xlm, whose `validate_domain` requires bytes in - # `[a-z]` only — no digits, no hyphens, no uppercase. Map run-id digits to - # the a–j range so we keep determinism + uniqueness. + # `[a-z]` only — no digits, no hyphens, no uppercase. Use a readable prefix + # plus a random lowercase tag; the chosen name is persisted to the state + # file below, so `finalize` reuses it. + # (`|| true` swallows the SIGPIPE `head` raises on `tr` under `pipefail`.) if [[ -z "${PROJECT_NAME:-}" ]]; then - short_id=$(printf '%s' "$RUN_ID" | tr '0-9' 'a-j') - PROJECT_NAME="ee${short_id: -10}" # 2 + 10 = 12 chars, all lowercase + rand=$(LC_ALL=C tr -dc 'a-z'