Deriving a boundary
One observed transaction goes in. A context rule and a policy set come out, narrow enough that everything adjacent to the observed flow is refused.
The pipeline
Four functions, in order. Each is pure and independently tested; none of them touches the network except the read at the front and the write at the back.
extract: what is read
extract turns a Soroban transaction into an ObservedTransaction: the contracts it invoked, the function names, the arguments, and the token movements attributable to the account the policy will install on.
The last part is the one that is easy to get wrong, and it was wrong once. The source is the account the policy installs on — not the envelope’s fee source. For a smart account moving its own funds those differ: the fee is paid by a classic account. synthesize counts a movement toward the cap only when it comes from source, so setting it to the fee source derived no cap at all and every boundary was refused at lowering. That defect was found by a browser and could not be reached by any Node test.
interface ObservedTransaction {
hash: string;
network: 'testnet' | 'mainnet' | 'simulated';
ledger: number;
source: Address; // the account the policy installs on
invocations: Invocation[];
movements: TokenMovement[];
attribution: 'exact' | 'transaction-level';
}attribution is carried rather than inferred. A single-invocation transaction attributes its movements exactly; a multi-invocation one can only attribute them at transaction level, and the UI says which it is holding rather than presenting both with the same confidence.
synthesize: what is derived
synthesize produces the narrowest proposal that permits the observation. Every dimension is taken from what happened:
| Dimension | Derived from | Refuses |
|---|---|---|
| contract | the contract ids actually invoked | a call to any other contract |
| function | the function names actually invoked | any other function on the same contract |
| asset | the token contract the movement was in | a transfer of any other token |
| amount | the outflow that actually occurred | a transfer above it, within the window |
| window | a ledger count chosen by the caller | the same call after the window closes |
The window is the one dimension not read off the transaction, because a transaction does not contain one. It is the single number a person supplies, and the interface offers a discrete set rather than a free field — the recorded run used 17,280 ledgers, ≈ 1 day.
When synthesis refuses
Synthesis fails rather than guessing. A transaction with no attributable outflow, or one whose movements cannot be tied to the installing account, produces a SynthesisError and no proposal — because the alternative is a boundary derived from an assumption, which is the thing this whole approach exists to avoid.
A refusal here is not a failure of the product. Deriving a permission from a transaction whose meaning is ambiguous would be.
lower: contract arguments
lower turns a proposal into the exact arguments the OpenZeppelin smart-account interface expects — a context rule naming the signer and the contract, and a spending-limit policy carrying the cap and window. It generates no Rust and compiles nothing. The policy contract is already deployed and audited; lowering configures it.
This is where a proposal that cannot be represented on chain is rejected, before anything is signed. A cap of zero, a window of zero ledgers, or a proposal naming no contract all fail here rather than at the network.
A derivation on record
The following boundary was derived from a transaction observed on live testnet. Both numbers are read from the recording.
- Observed transaction
- 525d5cf0…a35e
- Function
- transfer
- Outflow observed
- 0.1
- Cap derived
- 0.1
How this was producedstep 1 of /app/simulator, driven by hand in a browser — not by scripts/ and not by the test suite. This transaction was not piped into the install below. The recorded install was built by scripts/testnet.mjs from the same parameters the lowering produces; ingest-to-install in one pass needs a browser signer, which does not exist yet. Both halves are real and they are recorded as two runs, because that is what they are.