LIMEN
TESTNET

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.

TESTNET ONLYCOMPOSITION ONLY

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.

How a boundary is derivedA transaction observed on chain is passed through extract, then synthesize, then lower, producing a context rule and policy set that is installed on the smart account in one signed call. The derived cap equals the observed outflow.OBSERVEDtransactionon a ledgerextract()contracts, fnssynthesize()narrowest rulelower()contract argsinstall1 callderived cap = observed outflow, exactlynot a round number near it

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:

DimensionDerived fromRefuses
contractthe contract ids actually invokeda call to any other contract
functionthe function names actually invokedany other function on the same contract
assetthe token contract the movement was ina transfer of any other token
amountthe outflow that actually occurreda transfer above it, within the window
windowa ledger count chosen by the callerthe 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.