Skip to content

fix: change on_auth to return Result<(), PluginRejection> and fix error classification - #115

Open
alberto-crossmint wants to merge 5 commits into
mainfrom
fix/plugin-custom-error-classification
Open

fix: change on_auth to return Result<(), PluginRejection> and fix error classification#115
alberto-crossmint wants to merge 5 commits into
mainfrom
fix/plugin-custom-error-classification

Conversation

@alberto-crossmint

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

Copy link
Copy Markdown
Collaborator

Summary

Fixes plugin error classification in call_plugins_on_auth by changing the on_auth interface and replacing the dead-code match arms.

Problems solved

  1. Dead-code match armson_auth returned (), so the #[contractclient] macro set E = soroban_sdk::Error. Since TryFrom<Error> for Error is the identity, all errors landed in Err(Ok(_)) and the InvokeError::Abort / InvokeError::Contract arms were unreachable.

  2. Custom error rejections ignored — A plugin using panic_with_error! with its own #[contracterror] type had its rejection silently skipped.

How it's fixed

on_auth now returns Result<(), PluginRejection> — ABI backwards compatible because Ok(()) produces the same wire format (Void) as the old () return. Old deployed plugins continue working without recompilation.

With E = PluginRejection (a real #[contracterror]), all five match arms are now reachable:

Arm Meaning Action
Ok(Ok(_)) Approved (new or old-style void return) Continue
Ok(Err(_)) ABI mismatch Skip
Err(Ok(_)) New-style Err(PluginRejection::Rejected) Block auth
Err(Err(Contract(_))) Old-style panic_with_error! (backwards compat) Block auth
Err(Err(Abort)) Bare panic, missing function, host trap Skip

Test plan

14 plugin tests covering every old-style plugin pattern:

Test Plugin style Assertion
test_try_on_auth_result_variants All 9 plugin types via direct try_on_auth Exact Result variant for each
test_old_void_plugin_still_works () return Auth passes
test_old_void_plugin_with_storage_and_events_works () with storage writes + events Auth passes, side effects run
test_old_result_ok_plugin_works Result<(), Error> returning Ok Auth passes
test_old_result_err_plugin_blocks_auth Result<(), Error> returning Err Auth blocked
test_old_panic_with_known_error_blocks_auth panic_with_error!(SmartAccountError) Auth blocked
test_plugin_old_style_rejection_still_blocks panic_with_error!(CustomError) code 200 Auth blocked
test_plugin_new_style_rejection_blocks_auth Err(PluginRejection::Rejected) Auth blocked
test_plugin_bare_panic_skipped panic!("crashed") Auth passes (skipped)
test_plugin_missing_on_auth_skipped No on_auth function Auth passes (skipped)
test_mixed_plugins_crash_does_not_block DummyPlugin + PanickingPlugin Auth passes, Dummy runs
test_mixed_old_and_new_plugins_both_approve 3 approving plugins (void + Result + storage) All pass, side effects run
test_uninstall_plugin_persists_removal Install/uninstall lifecycle Counter stops after uninstall
test_max_plugins_limit 11 plugins 11th fails with MaxPluginsReached
  • cargo test --workspace — all 167 tests pass
  • cargo fmt --check — clean

The contractclient macro sets E = soroban_sdk::Error for void-returning
functions like on_auth, so TryFrom<Error> for Error is the identity and
ALL errors land in Err(Ok(soroban_sdk::Error)). The Err(Err(_)) arms
were dead code.

Replace the four-arm match with an inspection of Error.is_type():
- ScErrorType::Contract → deliberate rejection (panic_with_error! or
  contracterror Result::Err) — block authorization
- Anything else → technical failure (bare panic!, missing function,
  host trap, budget exhaustion) — skip plugin

This fixes a bug where a plugin using panic_with_error! with its own
#[contracterror] type (code outside SmartAccountError) had its rejection
silently ignored, and also a bug where bare panic! was incorrectly
blocking auth via the dead Err(Ok(_)) catch-all.

Tests added:
- Plugin with custom #[contracterror] (code 200) blocks auth
- Bare panic!() is skipped as technical failure
- Missing on_auth callback is skipped as technical failure
- Mixed plugins: crashing plugin doesn't block others
Change the plugin on_auth interface from returning () to
Result<(), PluginRejection>. This is ABI backwards compatible: old
plugins returning void produce the same wire format (Void) as new
plugins returning Ok(()). The dedicated PluginRejection error type
gives new plugins a clean rejection mechanism without relying on
panic_with_error!.

With E = PluginRejection (a real #[contracterror]), the
InvokeError::Abort vs InvokeError::Contract distinction is now
reachable and all five match arms are meaningful:
- Ok(Ok(_)):         approved (new or old-style)
- Ok(Err(_)):        ABI mismatch — skip
- Err(Ok(_)):        new-style Err(PluginRejection) — block
- Err(Err(Contract)): old-style panic_with_error! — block (compat)
- Err(Err(Abort)):   technical failure — skip

Add uninstall bypass: call_plugins_on_auth inspects auth contexts
for an uninstall_plugin call and skips the target plugin so it
cannot veto its own removal. This eliminates the permanent
wallet-lock scenario regardless of error classification.

Tests added:
- New-style rejection blocks auth
- Old-style panic_with_error! still blocks (backwards compat)
- Old void-returning plugin still works (ABI compat)
- Uninstall bypasses the rejecting plugin (escape hatch)
- Uninstall bypass only skips the target, not other plugins
@alberto-crossmint alberto-crossmint changed the title fix: classify plugin errors by ScErrorType, not InvokeError variant fix: classify plugin errors by return type and add uninstall bypass Apr 13, 2026
Remove the uninstall bypass logic — a plugin vetoing its own removal
is a valid use case. Keep the on_auth -> Result<(), PluginRejection>
interface change and the five-arm InvokeError match.
@alberto-crossmint alberto-crossmint changed the title fix: classify plugin errors by return type and add uninstall bypass fix: change on_auth to return Result<(), PluginRejection> and fix error classification Apr 13, 2026
Add 6 new tests (14 total plugin tests) to verify that every old-style
plugin pattern works correctly after on_auth changed to return
Result<(), PluginRejection>:

- test_try_on_auth_result_variants: calls try_on_auth directly on 9
  different plugin types and asserts the exact Result variant
  (Ok(Ok), Err(Ok), Err(Err(Contract)), Err(Err(Abort)))
- test_old_void_plugin_with_storage_and_events_works: void plugin
  that writes storage and emits events — verifies side effects run
- test_old_result_ok_plugin_works: plugin returning Result::Ok(())
- test_old_result_err_plugin_blocks_auth: plugin returning Result::Err
- test_old_panic_with_known_error_blocks_auth: panic_with_error! with
  SmartAccountError (the exact pre-#115 RejectingPlugin pattern)
- test_mixed_old_and_new_plugins_both_approve: three plugins (void,
  Result::Ok, void+storage) installed together all pass auth
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