feat(contract-factory): deploy_idempotent_and_call — atomic deploy + inner calls - #124
feat(contract-factory): deploy_idempotent_and_call — atomic deploy + inner calls#124alberto-crossmint wants to merge 2 commits into
Conversation
…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.
There was a problem hiding this comment.
💡 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".
| 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, |
There was a problem hiding this comment.
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 👍 / 👎.
Summary
Adds a new entrypoint to
ContractFactorythat lets callers submit a single top-level invocation which: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_callContractCall { target, function, args }— a call site, addressed by contract.DeployAndCallResult { address, results }— deployed address plusVec<Val>of inner returns, preserving order.try_invoke_contract::<Val, soroban_sdk::Error>so both host-level errors and contract-levelErr(...)returns are caught and remapped toFactoryError::InnerCallFailed.Typed errors across the surface
Introduces
FactoryError(contracterror,#[repr(u32)]) with:DeploymentFailed = 1InnerCallFailed = 2and converts
deploy,deploy_idempotent, andupload_and_deployto returnResult<Address, FactoryError>. Clients get the correspondingtry_*methods on the generated client for pattern-matching errors.Supporting changes
predict_and_check_deployed(env, args) -> (Address, bool)shared bydeploy_idempotentanddeploy_idempotent_and_call, so the “already-deployed?” check stays identical in both paths.ContractDeploymentArgsfields madepubto 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 whenrequire_authfires — regardless of call depth. This PR adds PoC tests that prove the behavior end-to-end:poc_tx_requires_auth_from_external_account_c— dispatchesgreet(C)as an inner call; asserts both the return value and thatenv.auths()contains the expected(C, greet, [C])pair. Theenv.auths()assertion is load-bearing: mocking auth makes a test compile, but only inspectingauths()proves the innerrequire_authwas actually consumed.poc_tx_without_c_auth_is_rejected— same setup, no auth mocks; confirms the tx is rejected and the failure surfaces asFactoryError::InnerCallFailed, not a panic.poc_tx_requires_auth_from_two_distinct_external_accounts— tworequire_authcalls in the same inner invocation; asserts bothC1andC2appear inenv.auths()against the sameAuthorizedFunction.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 strictmock_all_authshelper rejects that shape.Failure modes covered by tests
resultsis emptytest_deploy_idempotent_and_call_no_callstest_deploy_idempotent_and_call_multiple_calls_no_authrequire_authsatisfiedtest_deploy_idempotent_and_call_inner_auth_succeeds_when_authorizedrequire_authunsatisfiedErr(InnerCallFailed)test_deploy_idempotent_and_call_inner_auth_fails_without_authErr(InnerCallFailed), whole tx revertstest_deploy_idempotent_and_call_reverts_when_inner_panicsExisting idempotency / address-prediction tests were refactored to share a
default_deploymenthelper but their semantics are unchanged.Test plan
cargo build --release --target wasm32-unknown-unknown -p contract-factorycargo test -p contract-factory(15 passed, 0 failed)cargo clippy -p contract-factory --all-targets -- -D warnings🤖 Generated with Claude Code