Cycle

The Scriptorium

Smart Assembly code templates and tools for on-chain development in Eve Frontier.

TypeScript PTBs for EVE Frontier
AdvancedChapter 3 of 425 min read

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:

  • Borrow the cap from the Character
  • Use the cap in your operation
  • Return the cap to the Character
  • 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:

    typescript
    const { Transaction, Inputs } = await import('@mysten/sui/transactions');
    const tx = new Transaction();
    
    const characterType = ${EVE_FRONTIER_PACKAGE_ID}::character::Character;
    
    // 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::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:

    typescript
    // 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:

    typescript
    // 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:

    typescript
    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:

    typescript
    const tx = new Transaction();
    
    const xAuthType = ${TRIBE_STORAGE_PACKAGE_ID}::config::XAuth;
    const ssuType = ${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:

    typescript
    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:

    typescript
    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:

  • Order matters: Steps execute sequentially. Borrow must come before use, use must come before return.
  • Results are scoped to the transaction: You cannot save a result from one signAndExecute call and use it in another.
  • All-or-nothing: If any step fails, all steps are rolled back. The borrowed cap is never in a "dangling" state.
  • Gas is paid once: A 3-step PTB uses less gas than three separate transactions because it avoids repeated overhead.
  • Shared object contention: If your PTB touches shared objects (like 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 OwnerCap objects in EVE Frontier.
    • tx.moveCall() returns transaction references that can be passed to subsequent calls.
    • ReceivingRef requires the exact version and digest of 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.