Multi-Step PTBs
The real power of Programmable Transaction Blocks is composing multiple Move calls into a single atomic transaction. Intermediate results from one call can be passed as arguments to the next. This chapter covers the borrow-use-return pattern that is essential for EVE Frontier dApps.
Why Multi-Step?
In EVE Frontier, OwnerCap objects (which prove ownership of characters, SSUs, and other entities) are stored inside Character objects. You cannot use them directly -- you must:
All three steps must happen in a single PTB. If you tried to borrow in one transaction and use in another, the receipt (a hot potato) would be stranded and the transaction would fail.
The Borrow-Use-Return Pattern
Here is the full pattern from the AncientStorage dApp's share operation for non-owners:
const { Transaction, Inputs } = await import('@mysten/sui/transactions'); const tx = new Transaction(); const characterType =; // First, fetch the current version and digest of the OwnerCap const capRef = await fetchObjectRef(characterOwnerCapId); if (!capRef) throw new Error('Could not fetch OwnerCap<Character>'); // Step 1: BORROW — get OwnerCap and BorrowReceipt const [ownerCap, receipt] = tx.moveCall({ target:${EVE_FRONTIER_PACKAGE_ID}::character::Character${EVE_FRONTIER_PACKAGE_ID}::character::borrow_owner_cap, typeArguments: [characterType], arguments: [ tx.object(characterObjectId), tx.object(Inputs.ReceivingRef({ objectId: characterOwnerCapId, version: capRef.version, digest: capRef.digest, })), ], }); // Step 2: USE — call share() with the borrowed OwnerCap tx.moveCall({ target:${TRIBE_STORAGE_PACKAGE_ID}::tribe_storage::share, typeArguments: [characterType], arguments: [ tx.object(TRIBE_STORAGE_CONFIG_ID), tx.object(ssuId), tx.object(characterObjectId), ownerCap, // <-- result from step 1 tx.pure.u64(typeId), tx.pure.u32(quantity), ], }); // Step 3: RETURN — give back the OwnerCap with the receipt tx.moveCall({ target:${EVE_FRONTIER_PACKAGE_ID}::character::return_owner_cap, typeArguments: [characterType], arguments: [ tx.object(characterObjectId), ownerCap, // <-- same result from step 1 receipt, // <-- receipt from step 1 ], });
Passing Results Between Steps
When tx.moveCall() returns, the return value represents the Move function's return values. For functions that return multiple values, you destructure them:
// borrow_owner_cap returns (OwnerCap<T>, BorrowReceipt)
const [ownerCap, receipt] = tx.moveCall({
target: ${EVE_PKG}::character::borrow_owner_cap,
// ...
});These return values are transaction references -- they do not hold actual data. They are placeholders that the SUI runtime resolves when executing the transaction. You can pass them directly as arguments to subsequent moveCall invocations:
// ownerCap is a reference, not the actual object
tx.moveCall({
target: ${TRIBE_PKG}::tribe_storage::share,
arguments: [
// ...
ownerCap, // passed as a reference to the result of step 1
// ...
],
});The ReceivingRef Pattern
The ReceivingRef is needed when an object is stored inside another object (as a "received" object). The OwnerCap lives inside the Character object, so you must reference it with its exact version and digest:
tx.object(Inputs.ReceivingRef({
objectId: characterOwnerCapId,
version: capRef.version,
digest: capRef.digest,
}))If you pass a stale version or digest (because the object was modified by a concurrent transaction), the transaction will fail with an "object version mismatch" error.
Real-World Example: Authorize Extension
The authorize extension flow is another three-step PTB, but it operates on a different OwnerCap type:
const tx = new Transaction(); const xAuthType =; const ssuType =${TRIBE_STORAGE_PACKAGE_ID}::config::XAuth${EVE_FRONTIER_PACKAGE_ID}::storage_unit::StorageUnit; const capRef = await fetchObjectRef(ownerCapId); // Step 1: Borrow OwnerCap<StorageUnit> const [borrowedCap, receipt] = tx.moveCall({ target:${EVE_FRONTIER_PACKAGE_ID}::character::borrow_owner_cap, typeArguments: [ssuType], arguments: [ tx.object(characterObjectId), tx.object(Inputs.ReceivingRef({ objectId: ownerCapId, version: capRef.version, digest: capRef.digest, })), ], }); // Step 2: Authorize the extension tx.moveCall({ target:${EVE_FRONTIER_PACKAGE_ID}::storage_unit::authorize_extension, typeArguments: [xAuthType], arguments: [tx.object(ssuId), borrowedCap], }); // Step 3: Return the cap tx.moveCall({ target:${EVE_FRONTIER_PACKAGE_ID}::character::return_owner_cap, typeArguments: [ssuType], arguments: [tx.object(characterObjectId), borrowedCap, receipt], });
Note the type argument difference:
- Share operation:
borrow_owner_cap(borrowing the character's cap for inventory access) - Authorize extension:
borrow_owner_cap(borrowing the SSU owner cap for authorization)
Conditional Logic: Owner vs. Non-Owner
The AncientStorage dApp builds different PTBs depending on whether the caller is the SSU owner:
if (isOwner) {
// Owner path: simple single moveCall
tx.moveCall({
target: ${TRIBE_STORAGE_PACKAGE_ID}::tribe_storage::share_from_main,
arguments: [
tx.object(TRIBE_STORAGE_CONFIG_ID),
tx.object(ssuId),
tx.object(characterObjectId),
tx.pure.u64(typeId),
tx.pure.u32(quantity),
],
});
} else {
// Non-owner path: three-step borrow-use-return
const [ownerCap, receipt] = tx.moveCall({ / borrow / });
tx.moveCall({ / share with ownerCap / });
tx.moveCall({ / return ownerCap + receipt / });
}The conditional branching happens in TypeScript at build time -- the resulting PTB is either 1 step or 3 steps, but never both.
Detecting Ownership
Before building the PTB, you need to know if the caller is the SSU owner:
async function detectOwnership(
ssuId: string,
characterObjectId: string,
): Promise<boolean> {
const ssuData = await fetchObjectRef(ssuId);
const ownerCapId = ssuData?.ownerCapId;
if (!ownerCapId) return false;
const capData = await fetchObjectRef(ownerCapId);
return capData?.owner === characterObjectId;
}This checks the SSU's owner_cap_id field and then checks if that cap is owned by the connected character.
Transaction Composition Rules
When composing multi-step PTBs:
signAndExecute call and use it in another.ExtensionConfig), it may need to wait for consensus ordering. Minimize shared object mutations when possible.Key Takeaways
- Multi-step PTBs compose multiple Move calls into a single atomic transaction.
- The borrow-use-return pattern is mandatory for
OwnerCapobjects in EVE Frontier. tx.moveCall()returns transaction references that can be passed to subsequent calls.ReceivingRefrequires the exactversionanddigestof nested objects.- Conditional logic (owner vs. non-owner) is resolved in TypeScript at PTB build time.
- All steps in a PTB are atomic -- either all succeed or all are rolled back.
Sign in to track your progress.