algonaut 0.8.0

A Rusty sdk for the Algorand blockchain.
Documentation
---
id: closed-signed-transaction
title: SignedTransaction constructable only via signing
abstract: Close `SignedTransaction`'s pub fields, compute `transaction_id` from the transaction inside the signing paths, and add a `MultisigSigningSession` builder + `SignedLogic::sign` convenience so no example writes a `SignedTransaction` struct literal. Fifth sub-ADR addressing decision item D5 of the ideal-type-safe-ergonomic-api index.
status: accepted
date: 2026-05-20
deciders: []
tags: [api, ergonomics, type-safety, signing]
---

# SignedTransaction constructable only via signing

## Status

Accepted. Implements decision item **D5** of
[`ideal-type-safe-ergonomic-api`](ideal-type-safe-ergonomic-api.md).
Depends on **D7** ([`signer-trait`](signer-trait.md)) — the
`MultisigSigningSession` builder shares its accumulation logic with the
`MultisigSigner: Signer` impl from that ADR.

## Context

`examples/multi_sig.rs`, `examples/logic_sig_delegated.rs`,
`examples/logic_sig_delegated_multi.rs`, and the simulate step-defs all
build `SignedTransaction` literals:

```rust
SignedTransaction {
    transaction: t,
    transaction_id: "".to_owned(),   // placeholder
    sig,
    auth_address: None,
}
```

`transaction_id` is a derived value — the SHA-512/256 hash of the
transaction — yet the type lets callers store an empty string there. A
struct whose invariants are not enforced by its constructor will, given
enough call sites, be constructed wrong.

## Decision

`SignedTransaction`'s fields close to `pub(crate)`. Public access goes
through borrow-returning accessors:

```rust
impl SignedTransaction {
    pub fn transaction(&self) -> &Transaction;
    pub fn transaction_id(&self) -> &TxId;
    pub fn sig(&self) -> &TransactionSignature;
    pub fn auth_address(&self) -> Option<&Address>;
}
```

`transaction_id` is no longer a constructor argument — every signing
path computes it from `Transaction::id()` once when the
`SignedTransaction` is built.

### `MultisigSigningSession` builder

A fluent multi-step session for accumulating multisig signatures:

```rust
let signed = MultisigSigningSession::new(addr)
    .sign(txn, &alice)?       // -> InProgressMultisigSigningSession
    .sign_more(&bob)?         // -> InProgressMultisigSigningSession
    .finish()?;               // -> SignedTransaction
```

Two-state typestate: the first `sign(tx, &account)` consumes a
`MultisigSigningSession` (which only holds an address) and returns an
`InProgressMultisigSigningSession` (which now holds an address + a
transaction + a partial msig). Subsequent `sign_more(&account)?` calls
chain on the in-progress state. `finish()?` produces the
`SignedTransaction`.

The one-shot `MultisigSigner: Signer` impl from D7 keeps working — it
internally drives the same session, so the composer's per-slot signing
path is unchanged.

### `SignedLogic::sign` convenience

`SignedLogic` (used by the delegated logic-sig flow) gains a
`SignedLogic::sign(self, transaction) -> SignedTransaction` method so
`examples/logic_sig_delegated.rs` and friends stop writing struct
literals.

`SignedLogic`'s own `pub` fields stay open in this PR — closing them
needs a `LogicSigSession`-style entry point that's a follow-up. The
`SignedLogic { logic, args, sig }` literal still exists in two examples
and `tests/test_logic_signature.rs`; both produce `SignedLogic` for
`LogicSignature::DelegatedSig` / `DelegatedMultiSig`, which D5 leaves
untouched.

### `placeholder(tx)` for the composer

The atomic-transaction-composer's "unsigned slot" path (added in D7 to
replace the old `TransactionSigner::Empty` variant) needs to construct a
`SignedTransaction` with the all-zero placeholder signature from inside
the `algonaut` umbrella crate. `algonaut_transaction::signed_transaction::placeholder(tx)`
— `#[doc(hidden)] pub` — is the cross-crate ingress. Users never see
it; the composer's `gather_signatures` `None` branch is the only caller.

## Consequences

- **Compile-error breaking change** for every caller that wrote a
  `SignedTransaction { ... }` struct literal or read its fields
  directly across a crate boundary. Pre-1.0, no shim. The four examples
  and two step-defs that touched the literal form are migrated in this
  PR.
- **`transaction_id` cannot be wrong by construction.** The "store an
  empty string here, we'll fill it in later" footgun is gone.
- **Pluggable signers (D7) plus closed `SignedTransaction` (D5)
  compose**: a third-party HSM signer implements
  `Signer::sign_transactions` and produces `SignedTransaction`s through
  the same signing API the built-in signers use — no special access to
  closed fields needed.
- **Out of scope.** `SignedLogic`'s pub fields stay open; closing them
  is a future sub-ADR once a `LogicSigSession` design lands.
  `TransactionWithSigner` (the composer's signer-bearing tx record)
  keeps its `pub` fields — that's a separate type whose ergonomics are
  bound up with the upcoming `MethodCall` builder (D6).