Skip to content

feat(factory): permissionless deploy_treasury and constructor-bound wasm hash - #9

Merged
K1NGD4VID merged 1 commit into
mainfrom
feat/permissionless-deploy-treasury
Sep 15, 2026
Merged

K1NGD4VID merged 1 commit into
mainfrom
feat/permissionless-deploy-treasury

Conversation

@fadesany

Copy link
Copy Markdown
Collaborator

Summary

Two changes to the factory contract:

  1. Permissionless org creation. deploy_treasury no longer requires the single deployer account to sign; the new org's own admin is the only required signer. The deployer concept is removed completely.
  2. No front-run window on the wasm hash. initialize is replaced by __constructor(wasm_hash), so the treasury wasm hash is set in the same transaction that creates the factory. Previously anyone could call initialize first after deployment and choose the hash.

Not deployed anywhere. The existing testnet factories are unchanged.

For review: spam limit

Opening up deploy_treasury needed abuse mitigation. I chose a per-admin cooldown: one successful deploy per admin every DEPLOY_COOLDOWN_LEDGERS (720 ledgers, about an hour). A failed deploy does not start it.

  • Why not a fee: it needs a token, an amount and a recipient that nobody has decided yet. With no operator those would be fixed at deploy, and the recipient would bring back a privileged address.
  • Why not a lifetime cap per admin: it would permanently lock out legitimate admins running many orgs, and nobody could raise it.
  • Known weakness: it throttles one identity, not someone using many admin accounts. Each deploy still costs network fees and storage rent. If multi-account spam appears, a fee is the stronger option.

Changes

  • contracts/factory/src/lib.rs
    • __constructor(wasm_hash) replaces initialize.
    • deploy_treasury requires only admin.require_auth() and enforces the cooldown.
    • New pub const DEPLOY_COOLDOWN_LEDGERS: u32 = 720.
  • errors.rs: new DeployCooldown = 6. Codes 1 (NotInitialized), 2 (AlreadyInitialized) and 3 (NotDeployer) are retired and not reused, because clients may still decode them.
  • types.rs: DataKey::Deployer removed; LastDeploy(Address) added, kept in temporary storage.
  • scripts/deploy.sh: deploys the factory with -- --wasm_hash <hash>, with no separate initialize step.
  • README, SECURITY.md, script comments: describe the new model.

Why the front-run window is closed

  • Creation only: the host runs __constructor only while creating the contract (soroban-env-host 26.1.3, host/lifecycle.rs:81-86).
  • No later calls: it refuses direct calls to any __-prefixed function ("can't invoke a reserved function directly", host/frame.rs:907-921).
  • Nothing else writes the hash: no other function sets it.

Tests

stellar contract build then cargo test --workspace on the pinned Rust 1.92.0: 17 factory + 47 treasury = 64 passed, 0 failed.

New or changed factory tests:

  • Front-run: calling initialize or __constructor after deploy, with all auth mocked in the attacker's favour, fails. A real treasury still deploys afterwards, which proves the hash was not replaced.
  • Creating the factory without a wasm hash fails.
  • Any unrelated account can deploy an org as its own admin, and the only recorded signer is that admin.
  • Deploying without the admin's signature fails with Error(Auth, InvalidAction).
  • Cooldown: blocked in the same ledger and on the last ledger of the window, allowed exactly 720 ledgers later; separate per admin; not started by a failed deploy.

Follow-ups (not in this PR)

  • charter-app SDK: drop the initialize wrapper; update FactoryError (remove codes 1–3, add 6 with a message). The existing deployTreasury argument-order bug (it sends admin, name; the contract takes name, admin) also still needs fixing.
  • Deployment: deploy this version and point NEXT_PUBLIC_FACTORY_CONTRACT_ID / FACTORY_CONTRACT_ID, the README address and scripts/.env at it.
  • Issue factory: emit an event from initialize #2 and scripts/generate-issues.sh: still propose an event from initialize.
  • No getter exposes the stored wasm hash for on-chain verification.

🤖 Generated with Claude Code

…asm hash

Org creation no longer depends on a single operator account, and the
treasury wasm hash can no longer be front-run.

Permissionless deployment:
- deploy_treasury drops deployer.require_auth(); the org's own admin is
  the only required signer.
- The deployer concept is removed entirely (DataKey::Deployer, the
  initialize parameter, NotDeployer).
- Spam limit: each admin can deploy once per DEPLOY_COOLDOWN_LEDGERS
  (720 ledgers, ~1h), tracked in temporary storage. Failed deploys do
  not start the cooldown. New error DeployCooldown = 6.

Atomic wasm hash:
- initialize is replaced by __constructor(wasm_hash), so the hash is set
  in the transaction that creates the factory. The host refuses direct
  calls to reserved __ functions, so it cannot be set or replaced later.
- deploy.sh passes the hash as a constructor argument; there is no
  separate initialize step.

Error codes 1 (NotInitialized), 2 (AlreadyInitialized) and 3 (NotDeployer)
are retired and must not be reused.

Tests: 17 factory + 47 treasury pass, including front-run attempts via
initialize and __constructor, factory creation without a hash, admin-only
auth, and the cooldown boundaries.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018XtRyDqAujrnapqq74h27Y
@K1NGD4VID
K1NGD4VID merged commit 69ed30c into main Sep 15, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants