Step-by-step Execution
Use testing.createTraceIterationCursor() and TxCursor to execute transaction chains in parts, stop at a matching hop, and interleave multiple cursors
net.send() eagerly executes the whole transaction chain produced by a message.
That is the right default for most tests.
Use testing.createTraceIterationCursor() when you need control over when later hops execute:
- inspect the first few transactions of a long cascade
- assert intermediate state before downstream messages run
- interleave two independent message chains against the same emulated world
- discard the remaining tail intentionally
Prefer net.send() unless you specifically need partial execution. It keeps
tests shorter and easier to read.
Import @acton/emulation/testing in test files that use the cursor APIs from the testing namespace.
Mental Model
testing.createTraceIterationCursor(from, message) starts a cursor for one root message and returns a
TxCursor.
val iter = testing.createTraceIterationCursor(sender.address, msg);The cursor owns the pending tail of that message chain. Every execute...()
call:
- runs the next part of the chain
- mutates the same emulated blockchain state
- returns only the newly executed segment as
SendResultList
The main cursor methods are:
executeN(n)— execute up tontransactionsexecuteTill<Msg>(params)— execute until a matching transaction is reachedexecuteAllRemaining()— execute everything that is still pendingisDone()— check whether the cursor has no more pending transactionsclose()— discard the remaining tail
Execute the First Hop, Then Drain the Rest
Suppose a contract forwards a message to another contract and you want to check state between the first and second hop.
val forwardMessage = createMessage({
bounce: false,
value: ton("0.5"),
dest: forwarderAddress,
body: TriggerForward {
queryId: 7,
target: receiverAddress,
},
});
val iter = testing.createTraceIterationCursor(sender.address, forwardMessage);
val first = iter.executeN(1);
expect(first).toHaveLength(1);
expect(first).toHaveSuccessfulTx<TriggerForward>({
from: sender.address,
to: forwarderAddress,
});
// The forwarded message has not been processed yet.
expect(net.runGetMethod<int>(receiverAddress, "received")).toEqual(0);
expect(iter.isDone()).toBeFalse();
val tail = iter.executeAllRemaining();
expect(tail).toHaveLength(1);
expect(tail).toHaveSuccessfulTx<Notify>({
from: forwarderAddress,
to: receiverAddress,
});
expect(net.runGetMethod<int>(receiverAddress, "received")).toEqual(1);
expect(iter.isDone()).toBeTrue();This is the main difference from net.send(): after executeN(1), the
emulator state already reflects the first transaction, while the child message
is still queued inside the cursor.
Stop When a Specific Hop Appears
Use executeTill<Msg>(...) when you want to run a chain until a particular
transaction is reached.
val routeMessage = createMessage({
bounce: false,
value: ton("0.5"),
dest: routerAddress,
body: TriggerRoute {
queryId: 17,
relay: relayAddress,
sink: sinkAddress,
},
});
val iter = testing.createTraceIterationCursor(sender.address, routeMessage);
val untilRelay = iter.executeTill<Relay>({
from: routerAddress,
to: relayAddress,
});
expect(untilRelay).toHaveSuccessfulTx<TriggerRoute>({
from: sender.address,
to: routerAddress,
});
expect(untilRelay).toHaveSuccessfulTx<Relay>({
from: routerAddress,
to: relayAddress,
});
// The rest of the chain is still pending.
expect(iter.isDone()).toBeFalse();The matching transaction is included in the returned segment.
When you pass the generic message type, Acton automatically fills the opcode
part of the search from that message type. You can still narrow the match with
regular SearchParams filters.
If no matching transaction is found before the queue is exhausted,
executeTill() fails the test.
Interleave Two Message Chains
Each testing.createTraceIterationCursor() call creates its own cursor, so you can interleave two
chains against shared world state.
val beginMessage = createMessage({
bounce: false,
value: ton("0.5"),
dest: raceAddress,
body: Begin { queryId: 1 },
});
val attackMessage = createMessage({
bounce: false,
value: ton("0.5"),
dest: raceAddress,
body: Attack { queryId: 2 },
});
val beginIter = testing.createTraceIterationCursor(sender.address, beginMessage);
val attackIter = testing.createTraceIterationCursor(attacker.address, attackMessage);
val beginFirst = beginIter.executeN(1);
expect(beginFirst).toHaveSuccessfulTx<Begin>({
from: sender.address,
to: raceAddress,
});
expect(net.runGetMethod<int>(raceAddress, "stage")).toEqual(1);
val attackAll = attackIter.executeAllRemaining();
expect(attackAll).toHaveSuccessfulTx<Attack>({
from: attacker.address,
to: raceAddress,
});
expect(net.runGetMethod<int>(raceAddress, "attacked")).toEqual(1);
val beginTail = beginIter.executeAllRemaining();
expect(beginTail).toHaveSuccessfulTx<Finish>({
from: raceAddress,
to: raceAddress,
});
expect(beginTail.at(0).parentLt).toEqual(beginFirst.at(0).tx.load().lt);This pattern is useful for race conditions, ordering-sensitive flows, and "message in the middle" scenarios.
Discard the Remaining Tail
Use close() when the rest of the chain is irrelevant for the test and you want
to drop it explicitly.
val forwardMessage = createMessage({
bounce: false,
value: ton("0.5"),
dest: forwarderAddress,
body: TriggerForward {
queryId: 88,
target: receiverAddress,
},
});
val iter = testing.createTraceIterationCursor(sender.address, forwardMessage);
val first = iter.executeN(1);
expect(first).toHaveLength(1);
iter.close();
expect(iter.isDone()).toBeTrue();
expect(iter.executeAllRemaining()).toBeEmpty();Closing the cursor discards any pending internal tail that has not been executed yet.
What You Get Back
Every execute...() call returns the same result shape you already know from
net.send(): a SendResultList.
That means you can keep using the usual tools:
- transaction matchers such as
toHaveTx()andtoHaveSuccessfulTx() - direct access to
tx,parentLt,childTxs,outMessages, andexternals println(segment)to inspect the executed batch in the terminal
This also makes it easy to split one logical scenario into several assertions without inventing a second assertion API just for step-by-step execution.
Limits and Caveats
testing.createTraceIterationCursor()is available only in emulation mode. It is not a broadcast workflow.- Step-by-step execution is not supported in debug mode yet.
executeN(0)is a no-op and leaves the cursor untouched.executeTill()fails if the queue is exhausted before a match is found.close()discards pending work; it does not roll the state back.
If you need rollback or checkpoints together with partial execution, combine
testing.createTraceIterationCursor() with
testing.saveSnapshot() and testing.loadSnapshot(), or see World State Snapshots.
See Also
Last updated on