Skip to content

feat(contract-factory): deploy_idempotent_and_call — atomic deploy + inner calls - #124

Open
alberto-crossmint wants to merge 2 commits into
mainfrom
alb/generic-deploy-and-call
Open

feat(contract-factory): deploy_idempotent_and_call — atomic deploy + inner calls#124
alberto-crossmint wants to merge 2 commits into
mainfrom
alb/generic-deploy-and-call

Conversation

@alberto-crossmint

@alberto-crossmint alberto-crossmint commented Apr 21, 2026

Copy link
Copy Markdown
Collaborator

Summary

Adds a new entrypoint to ContractFactory that lets callers submit a single top-level invocation which:

  1. Idempotently deploys a contract at its deterministic address (skipping if already deployed), and
  2. Dispatches an arbitrary sequence of inner contract calls,

returning the deployed address and each inner call's raw return value. If any inner call fails, the whole transaction is rolled back and the caller receives the typed FactoryError::InnerCallFailed — not a raw host abort.

This unlocks flows like “deploy this smart account if it doesn't exist yet, and then in the same tx call init(...) / add_signer(...) / transfer funds / etc.” without a separate follow-up transaction — and without giving up typed error reporting.

What changed

New API — deploy_idempotent_and_call

pub fn deploy_idempotent_and_call(
    env: &Env,
    deployment_args: ContractDeploymentArgs,
    calls: Vec<ContractCall>,
) -> Result<DeployAndCallResult, FactoryError>
  • ContractCall { target, function, args } — a call site, addressed by contract.
  • DeployAndCallResult { address, results } — deployed address plus Vec<Val> of inner returns, preserving order.
  • Inner dispatch uses try_invoke_contract::<Val, soroban_sdk::Error> so both host-level errors and contract-level Err(...) returns are caught and remapped to FactoryError::InnerCallFailed.

Typed errors across the surface

Introduces FactoryError (contracterror, #[repr(u32)]) with:

  • DeploymentFailed = 1
  • InnerCallFailed = 2

and converts deploy, deploy_idempotent, and upload_and_deploy to return Result<Address, FactoryError>. Clients get the corresponding try_* methods on the generated client for pattern-matching errors.

Supporting changes

  • New helper predict_and_check_deployed(env, args) -> (Address, bool) shared by deploy_idempotent and deploy_idempotent_and_call, so the “already-deployed?” check stays identical in both paths.
  • ContractDeploymentArgs fields made pub to allow construction from external crates / SDK callers.

Authorization model (the interesting bit)

A key design question for this method is: if an inner call invokes require_auth() on some external account C, how does that get satisfied? The factory is the top-level contract, not C, so C's auth is non-root in the resulting auth tree.

The answer is the standard Soroban rule: the transaction envelope carries auth entries for every (address, sub-invocation) pair that will require auth, and the host matches them when require_auth fires — regardless of call depth. This PR adds PoC tests that prove the behavior end-to-end:

  • poc_tx_requires_auth_from_external_account_c — dispatches greet(C) as an inner call; asserts both the return value and that env.auths() contains the expected (C, greet, [C]) pair. The env.auths() assertion is load-bearing: mocking auth makes a test compile, but only inspecting auths() proves the inner require_auth was actually consumed.
  • poc_tx_without_c_auth_is_rejected — same setup, no auth mocks; confirms the tx is rejected and the failure surfaces as FactoryError::InnerCallFailed, not a panic.
  • poc_tx_requires_auth_from_two_distinct_external_accounts — two require_auth calls in the same inner invocation; asserts both C1 and C2 appear in env.auths() against the same AuthorizedFunction.

Tests use mock_all_auths_allowing_non_root_auth() because C's auth is required by a sub-invocation that the factory (the root) does not itself declare — the strict mock_all_auths helper rejects that shape.

Failure modes covered by tests

Scenario Expected behavior Test
Zero inner calls Deploy only, results is empty test_deploy_idempotent_and_call_no_calls
Multiple calls, no auth required All calls executed in order test_deploy_idempotent_and_call_multiple_calls_no_auth
Inner require_auth satisfied Succeeds, returns caller test_deploy_idempotent_and_call_inner_auth_succeeds_when_authorized
Inner require_auth unsatisfied Err(InnerCallFailed) test_deploy_idempotent_and_call_inner_auth_fails_without_auth
Inner call panics Err(InnerCallFailed), whole tx reverts test_deploy_idempotent_and_call_reverts_when_inner_panics

Existing idempotency / address-prediction tests were refactored to share a default_deployment helper but their semantics are unchanged.

Test plan

  • cargo build --release --target wasm32-unknown-unknown -p contract-factory
  • cargo test -p contract-factory (15 passed, 0 failed)
  • cargo clippy -p contract-factory --all-targets -- -D warnings
  • Exercise against a deployed testnet factory with a real off-chain auth entry for the inner call

🤖 Generated with Claude Code

…loy + inner calls

Adds `deploy_idempotent_and_call`, which idempotently deploys a contract
at its deterministic address and then dispatches a sequence of inner
contract calls in the same top-level invocation. Inner-call failures
surface as a typed `FactoryError::InnerCallFailed` rather than a host
abort, and `require_auth` inside inner calls continues to be enforced
by Soroban's normal auth rules — auth entries in the transaction
envelope propagate to sub-invocations.

- Introduces `FactoryError` (`contracterror`) and converts `deploy`,
  `deploy_idempotent`, and `upload_and_deploy` to return `Result`.
- Adds `ContractCall` and `DeployAndCallResult` public types.
- Extracts a `predict_and_check_deployed` helper shared between
  `deploy_idempotent` and the new method.
- Adds PoC tests that prove external-account auth is required and
  consumed during inner calls (asserting against `env.auths()`), plus
  failure-mode tests for missing auth and panicking inner calls.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 173f52f82b

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread contracts/contract-factory/src/lib.rs
Comment on lines 134 to +136
let derived_salt = Self::derive_salt(env, salt, &wasm_hash, &constructor_args);
Self::deploy_and_emit(env, derived_salt, wasm_hash, constructor_args)
Ok(Self::deploy_and_emit(
env,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Return DeploymentFailed instead of always wrapping deploy in Ok

The new Result<..., FactoryError> surface never actually emits FactoryError::DeploymentFailed: deploy (and similarly deploy_idempotent/upload_and_deploy) unconditionally wraps deploy_and_emit in Ok(...). When deployment fails (e.g., duplicate deployment path already covered by tests), deploy_v2 traps before a Result is returned, so callers cannot pattern-match the typed deployment error this API now advertises.

Useful? React with 👍 / 👎.

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.

1 participant