Skip to main content

Invariant

A3

Category

Algorand

Priority

Stretch

Violation class

Asset Theft (authorization)

What it proves

Algorand accounts can be rekeyed — their spending authority reassigned to a different key, without changing the account’s address. A3 asks: if an account is rekeyed after signing a payment but before that payment settles, does the facilitator still honor a signature from a key that’s no longer actually in control of the account? Source: Algorand rekeying (protocol primitive); settle() must not trust spending authority captured before a mid-flow rekey.

Why this runs on a disposable account

A rekey is a real, potentially hard-to-reverse on-chain state change. A3 generates a brand-new, purpose-funded throwaway account rather than reusing the shared fixture client account every other invariant depends on — if the “rekey back” revert step ever failed to execute (a crashed process, a network partition mid-run), a shared account left rekeyed to a discarded key would strand every other invariant for the rest of that run, and any future run reusing the same .env. Isolating the blast radius to one small, disposable, funded-just-for-this-test account means the worst case is one clearly-logged account being stuck — not the whole suite. See D11. The revert always runs, unconditionally, in a finally block; a failed revert is logged loudly (ctx.logger.warn) rather than silently swallowed.

How it works

1

Fund a throwaway account

A fresh keypair is funded with just enough ALGO to cover its minimum balance (including ASA opt-in) plus a handful of fees, via ChainAdapter.fundAccount().
2

Sign a payment under the original key

A zero-amount payment (proving spending authority is what’s under test, not moving real value) is built and signed under the account’s original Ed25519 key.
3

Rekey the account on-chain

The account is rekeyed to a brand-new authority address, mid-flow, after the payment above was already signed.
4

Submit the stale-key-signed payload

The payload signed before the rekey is submitted to /settle, after the rekey has already landed on-chain.
5

Always revert the rekey

Regardless of the settle outcome, the account is rekeyed back to itself in a finally block.

The real code path this targets

Per D7 #4, settle() unconditionally re-runs verify() from scratch — it never trusts a previously-cached /verify decision. The interesting question is whether that fresh verify checks spending authority against the account’s current on-chain auth-addr, or only against the inherent ed25519 key embedded in the transaction sender field (which never changes even after a rekey). The facilitator’s local ed25519Verifier pre-check uses the latter — a potential blind spot — but the subsequent simulateTransactionGroup() call runs a real algod simulation, which does enforce actual on-chain auth-addr rules. A3 tests the net effect end-to-end rather than assuming which internal layer would catch it.

Pass condition

/settle must reject the stale, pre-rekey-signed authorization. A PASS here is genuinely useful evidence either way — it’s a confirmed result, not an assumption about which internal check would catch it.

Back to the full picture