Skip to content

fix(sdk): honor explicit outputSatoshis user parameter in stateful method calls#105

Open
E-Jacko wants to merge 1 commit into
icellan:mainfrom
E-Jacko:fix/method-call-output-satoshis
Open

fix(sdk): honor explicit outputSatoshis user parameter in stateful method calls#105
E-Jacko wants to merge 1 commit into
icellan:mainfrom
E-Jacko:fix/method-call-output-satoshis

Conversation

@E-Jacko

@E-Jacko E-Jacko commented Jun 26, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds a new resolution rung to the SDK's continuation-output satoshi computation so that when a stateful method declares an explicit public ABI parameter named outputSatoshis, the SDK uses the user-supplied value for the continuation output's amount.

Without this fix the SDK silently falls back to the input UTXO's satoshi count, which produces a post-broadcast OP_EQUALVERIFY failure (ARC HTTP 461) for any compiled contract that bakes the user's outputSatoshis into a hashOutputs covenant.

Existing resolution priorities are preserved: CallOptions.satoshis (caller override) still outranks the new rung, and methods that don't declare outputSatoshis continue to default to current_utxo.satoshis.

Problem

Upstream HEAD packages/runar-rs/src/sdk/contract.rs:720-725:

new_satoshis = Some(
    options
        .and_then(|o| o.satoshis)
        .unwrap_or(current_utxo.satoshis),
);

There is no rung in this resolution that consults the user's declared ABI args. If a stateful contract method declares:

public method transfer(amount: Int, recipientPk: PubKey, outputSatoshis: Int, sig: Sig) { ... }

…and the user calls it with outputSatoshis = 3000 on an input UTXO that holds 5000 sats, the SDK builds a continuation output of 5000 sats while the contract body computes its hashOutputs preimage with the user's 3000. The on-chain OP_HASH256(serialize(output)) == committed_hash check fails, ARC rejects with HTTP 461.

How this surfaced

Surfaced while building a stateful contract whose methods accept user-declared continuation-amount arguments. Methods that change the contract's locked satoshi count (split-style, partial-withdrawal-style, value-resize-style patterns) all declare outputSatoshis as a public ABI param. The compiler bakes the user's value into a hashOutputs covenant check — without SDK-side resolution, the call broadcasts a non-matching-hash output and ARC rejects with HTTP 461. This was first observed on mainnet during a multi-step lifecycle where each step shrinks the contract's locked amount; every method invocation hit the same rejection until a vendored workaround injected the right satoshi value.

Fix

Adds a third rung beneath the existing CallOptions.satoshis fallback:

new_satoshis = Some(resolve_continuation_satoshis(
    &user_param_names,
    &resolved_args,
    options.and_then(|o| o.satoshis),
    current_utxo.satoshis,
));

Where resolve_continuation_satoshis is a new pub fn in contract.rs:

/// Resolve the satoshi amount for a stateful contract's continuation output
/// at the moment the SDK builds the spending transaction.
///
/// Resolution order (highest priority first):
///   1. `options.satoshis` (caller override via `CallOptions`)
///   2. User's `outputSatoshis` arg at the matching position in
///      `resolved_args` when the method declares it as a public param
///   3. Legacy fallback to `current_utxo_satoshis`
pub fn resolve_continuation_satoshis(
    user_param_names: &[&str],
    resolved_args: &[SdkValue],
    options_satoshis: Option<i64>,
    current_utxo_satoshis: i64,
) -> i64 {
    let user_outputs_satoshis = user_param_names
        .iter()
        .position(|n| *n == "outputSatoshis")
        .and_then(|i| match resolved_args.get(i) {
            Some(SdkValue::Int(v)) => Some(*v),
            _ => None,
        });
    options_satoshis
        .or(user_outputs_satoshis)
        .unwrap_or(current_utxo_satoshis)
}

The helper is extracted from the inline call site (rather than left inline) so that the regression test can drive it through public surface without standing up a full stateful-contract deploy + call cycle. Production routes through the same helper; there is no "test passes / production diverges" risk.

Candid asymmetry against TS

The TS SDK's continuation-amount default lives at runar-sdk/src/contract.ts:894:

newSatoshis = options?.satoshis ?? this.currentUtxo.satoshis;

The Rust upstream at runar-rs/src/sdk/contract.rs:720-725 already matches this two-rung shape. This PR introduces a third rung beneath them (read the user's declared outputSatoshis ABI arg) — and that third rung does not exist in canonical TS today.

This is a genuine asymmetry. The shape this PR adds is consistent with how the compiler emits outputSatoshis-bearing contracts: the compiler bakes the user's value into the hashOutputs covenant, so without SDK-side resolution the call broadcasts a non-matching-hash output and ARC rejects 461. The Rust patch is compiler-aligned with the source of truth (what the contract does on-chain), even though it's not strictly SDK-symmetric with TS today.

Framed honestly: the Rust SDK catches up to what the Rust compiler emits. The TS SDK has the same latent gap and is likely to need a twin patch when downstream consumers hit the same on-chain rejection — but that's outside the scope of this PR. Happy to file the TS twin in parallel if maintainers prefer symmetry-first.

Verification

git clone --depth 1 https://github.com/icellan/runar /tmp/runar
cd /tmp/runar
git apply <runar-r3-method-call-outputsatoshis-clean.patch>
cd packages/runar-rs
cargo test --test r3_output_satoshis

Passes with patch:

running 4 tests
test r3_call_options_satoshis_beats_user_arg ... ok
test r3_legacy_no_output_satoshis_param_falls_back_to_input_utxo ... ok
test r3_user_output_satoshis_arg_overrides_input_utxo ... ok
test r3_param_declared_but_arg_not_int_falls_back ... ok

test result: ok. 4 passed; 0 failed

Fails without patch (surgical — drop only the user-arg rung in resolve_continuation_satoshis):

failures:

---- r3_user_output_satoshis_arg_overrides_input_utxo stdout ----
assertion `left == right` failed: R3 regression: continuation must use user's outputSatoshis arg (3000), not the input UTXO's satoshis (5000). Found: 5000
  left: 5000
 right: 3000

failures:
    r3_user_output_satoshis_arg_overrides_input_utxo

test result: FAILED. 3 passed; 1 failed

The three other tests intentionally lock the non-changed behavior — they pass pre- and post-patch, proving the patch did not regress (a) the CallOptions.satoshis override, (b) the legacy fallback when no outputSatoshis param is declared, (c) the behaviour when the param is declared but the arg is not an Int.

Backwards compatibility

  • Purely additive. Methods that don't declare an outputSatoshis public param see byte-identical behaviour (the new helper falls through to the legacy current_utxo.satoshis rung).
  • CallOptions.satoshis continues to be the highest-priority override — explicit caller intent always wins.
  • When the param IS declared but the arg is not SdkValue::Int(_) (e.g. left as Auto), the helper falls through — locked by r3_param_declared_but_arg_not_int_falls_back.
  • New pub fn resolve_continuation_satoshis is genuinely useful pub surface for any third-party test against the SDK's continuation logic; it's not a test-only hatch.

Related

  • PR-04 — runar broadcast and get_transaction: R1 in that PR surfaces the ARC HTTP 461 rejections that R3 actually fixes — the two are paired in the failure timeline (R1 makes the 461 visible; R3 eliminates one of the root causes).
  • TS twin: not in this PR. If maintainers prefer symmetry-first, happy to file the TS counterpart in parallel — the Rust patch can hold for review until the TS twin lands.

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