OpenZeppelin / OpenZeppelin/stellar-contracts
Testnet confirmation of the v0.9.0 smart-account client flow (External and Delegated signers)
Nobody has claimed this yet.
- Dominant language
- Rust
- Stars
- 95
- Forks
- 68
- Avg merge
- 4d 42m
- Merged PRs (30d)
- 20
Description
AI-assisted. The scripts behind this report and the first draft of this
text were written with an AI coding assistant (Claude). I have reviewed the
scripts, checked every transaction below on a block explorer, and edited this
text myself before posting. Your contributing guide asks for that scrutiny,
so I would rather say it plainly than leave you to guess.
#868 (fixing #876) moved signers to AuthDigestPreimage and added
test/auth_entries.rs, which builds both signers' authorization entries and
drives them through __check_auth. Those tests run in an in-memory Env with
set_auths. What they cannot exercise is RPC simulation, which is where #839
and #863 went wrong: simulation does not return the Delegated signer's entry,
and a client that trusts it submits a transaction that looks complete and
traps.
So I ran the flow the new "Authorizing from a Client" section describes against
a real deployment, from a TypeScript client. It works as documented. Every
case below behaved the way the README says it should, including the
cross-account replay #876 describes, which succeeds against 0.7.2 accounts and
is refused by 4529d70 ones. A few observations that are not in the README are at
the end. Nothing here proposes a code change.
Setup
stellar-accountsfrom branchv0.9.0at
4529d708c47866bec790223b88036f9aa9e404b3(the #868 merge), pinned as a git
dependency. Unpublished at the time; the crate's version field still reads
0.7.1. On 2026-09-18 the branch head isc008f2d, 8 commits later, none of
which touchpackages/accounts, and crates.io still lists 0.7.2 as the
latest. I have not re-run againstc008f2d.soroban-sdk27.0.6, not the 27.0.2 in your lockfile at that commit: our
workspace already resolves 27.0.6 and Cargo keeps one version per compatible
range. Built withstellar contract build(stellar-cli 28.0.0).- Testnet, protocol 28. Client-flow cases in ledgers 4644335–4644359.
@stellar/stellar-sdk16.3.0.simulateTransaction(tx)with noauthMode
argument, i.e. the RPC default. Recording-mode results are read from the raw
response (_simulateTransaction), not the SDK's summary.- Contracts, mirroring
auth_entries.rs:- smart account (
SmartAccount+CustomAccountInterfacedelegating to
do_check_auth):CBBUT7…L6OO - probe with
ping(caller) { caller.require_auth() }and a no-argument
ping_owner()(see the replay section):CC36Q5…PJ5E - ed25519 verifier wrapping
verifiers::ed25519:CD3353…NNS2 - rule 1:
CallContract(probe)with anExternaled25519 key; rule 2:
CallContract(probe)with aDelegatedG-account
- smart account (
- Client-side preimage:
ScVal::Mapwith keysaccount,context_rule_ids,
signature_payload, SHA-256 over its XDR
Each refused case was also submitted, not only simulated. A failed
simulation returns no footprint, so the transaction borrowed one from a
correctly signed copy with the same nonces and expiration (simulated, never
sent). The ledger keys are the same, and the errors below come from the
ledger's own diagnostic events, not from simulation.
External signer
| # | Case | Simulation with signed entries | On chain |
|---|---|---|---|
| E1 | Client digest vs auth_digest(preimage) view, simulated |
Equal: d4bf07e4…c87e both ways, for signature_payload ca438a0e…3967, rule [1] |
— |
| E2 | Sign sha256(preimage.to_xdr()) |
ok | Success, ledger 4644344 |
| E3 | Sign the raw signature_payload |
Error(Auth, InvalidAction), Error(Crypto, InvalidInput) |
Failed, ledger 4644345: failed ED25519 verification → Error(Crypto, InvalidInput) → Error(Auth, InvalidAction) |
| E4 | Sign the 0.7.x digest, sha256(signature_payload ‖ context_rule_ids.to_xdr()) |
Same as E3 | Failed, ledger 4644347, same errors as E3 |
E4 is the case a client that missed the migration will hit.
For E1, the same preimage with its keys in declaration order (account,
signature_payload, context_rule_ids) is refused before the view runs:
HostError: Error(Object, InvalidInput), "ScMap was not sorted by key for
conversion to host object". So the sorted order is the only one the host
accepts, and the view is a good way to find that out.
Delegated signer
| # | Case | Simulation with signed entries | On chain |
|---|---|---|---|
| D0 | Recording-mode simulation of probe.ping(account): entries in raw auth[] |
One, for the smart account CBBUT7…L6OO; none for the delegate |
— |
| D1 | Account entry + hand-added root entry for the delegate, invocation __check_auth on the account, argument the preimage map, signed ed25519 |
ok | Success, ledger 4644348 |
| D2 | Account entry only | Error(Auth, InvalidAction) |
Failed, ledger 4644349: "Unauthorized function call for address" GDUQ…D5TS from require_auth_for_args, trapped in __check_auth → Error(Auth, InvalidAction) |
| D3 | Delegate entry whose argument is the 32-byte digest, not the map | Error(Auth, InvalidAction) |
Failed, ledger 4644350: identical diagnostic sequence to D2 |
In our 0.7.2 work, recording-mode simulation never ran __check_auth, so a
rejection showed up only on a second simulation with the signed entries
attached. That holds here too, and the second simulation caught every
refusal (E3, E4, D2, D3) before submission. A client that re-simulates with
its signed entries does not need to reach the ledger to find out.
Account binding (#876)
The binding is only observable with an invocation that does not name the
account; probe.ping(account) already differs per account. So the probe also
stores an owner address, and ping_owner() calls owner.require_auth() with no
arguments. Two accounts list the same External key under a
CallContract(probe) rule with the same rule id (1). Sign for account A and
submit; point the probe at B; submit A's entry for B with only the address
changed. I compared the two transactions' envelopes: the signature ScVal, the
nonce, the expiration ledger and the invocation are byte-identical, and the
client computed the same signature_payload for both.
| # | Accounts built from | Signed for A | A's entry replayed against B |
|---|---|---|---|
| R1 | stellar-accounts 0.7.2 |
Success, ledger 4644353 | Accepted, ledger 4644355, same nonce 8746352541701827608. The gap #876 describes |
| R2 | 4529d70 |
Success, ledger 4644357 | Refused, ledger 4644359: Error(Crypto, InvalidInput) → Error(Auth, InvalidAction). Also refused at simulation |
R1 also confirms the premise: nonces are tracked per authorizing address, so
the nonce A had just consumed was unused for B.
Result
Confirmed as documented. External signers verify against
sha256(preimage.to_xdr()) and nothing else. The auth_digest view agrees with
an independent TypeScript encoding. Simulation does not return the delegated
signer's entry, and the manual second root entry the README describes works.
A signature collected for one 4529d70 account is refused by another that lists
the same key, while the same replay goes through on 0.7.2.
Observations the README does not currently state, none of them a bug:
- External signer failures never surface as
ExternalVerificationFailed
(3003) with the shipped ed25519 verifier.verifiers::ed25519::verify
calls the host'sed25519_verify, which traps instead of returningfalse
(its doc says so), soauthenticate's!verify(..)branch is not reached.
A wrong digest, a wrong key and a cross-account replay all read as
Error(Crypto, InvalidInput)thenError(Auth, InvalidAction). The
"Authorizing from a Client" section describes the delegated failure mode
but not this one. - D2 (entry missing) and D3 (entry with the wrong argument) are
indistinguishable on chain. Both are "Unauthorized function call for
address" fromrequire_auth_for_args. The README says as much for
"missing or wrongly encoded"; noting that the diagnostic text does not
separate them either. - Neither delegated failure showed the
Error(WasmVm, InvalidAction)/
UnreachableCodeReachedtrace quoted in #839. The trap here is a
HostErrorescalated fromrequire_auth_for_args, so #839's trace may have
had a different immediate cause. I have not reproduced #839's construction.
Questions
- Would the client script be useful to you, and where? It is TypeScript, so
possibly not in this repository. Options I can see: a gist linked from the
docs' "Transaction Simulation Behavior" section, or nowhere. Happy with
either. - Minor, noticed while reading: the migration guide #868 adds is headed
"from v0.7.x to 0.8.0", but the change is on thev0.9.0branch and not in
v0.8.0-rc.3. If 0.8.0 is not going to carry it, the heading may mislead.
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start with the README's "Authorizing from a Client" and "Transaction Simulation Behavior" sections, then compare them with the testnet observations and the migration guide in #868. Review the referenced TypeScript client flow and decide whether the script should be linked or documented, and whether the migration heading needs clarification.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- rust, typescript
- Domain
- documentation, testing
- Issue type
- Documentation
- Difficulty
- 5/5
- Estimated time
- Over a week
- Activity status
- Active
- Clarity
- Needs clarification
- Newbie friendliness
- 25/100