Sponsored Transaction TypeScript Tutorial

How to sponsor transactions with Shinami on Sui

In this tutorial, we will go over step-by-step how to construct multiple types of transactions, get them sponsored by Shinami's Gas Station, and submit them for execution on the Sui blockchain. The full code for this TypeScript tutorial is available on GitHub.

For our examples, we'll be constructing our programmable transactions using the Sui TypeScript SDK provided by Mysten. We'll also use the typed-rpc library to make JSON-RPC calls to the Shinami Gas Station. Code sections that require substitution of custom values are stubbed with <>.

Let's start by importing these libraries:

import { 
    Connection, 
    Ed25519Keypair, 
    JsonRpcProvider, 
    RawSigner, 
    TransactionBlock
} from "@mysten/sui.js";
import { rpcClient } from "typed-rpc";

Next, let's set the parameters we'll need for our examples:

// gas budget for our transactions, in MIST
const GAS_BUDGET = 5000000;

// Shinami Gas Station endpoint:
const SPONSOR_RPC_URL = "https://api.shinami.com/gas/v1/<GAS_ACCESS_KEY>";

// Shinami Sui Node endpoint:
const connection = new Connection({
    fullnode: 'https://api.shinami.com/node/v1/<NODE_ACCESS_KEY>'
});
const suiProvider = new JsonRpcProvider(connection);

// The sui address of the initiator of the transaction
const SENDER_ADDRESS = "<SUI_ADDRESS>";

// We will need a keypair and signer for this SENDER_ADDRESS. You can do this in one of two ways:

// Create the sender's key pair from the sender's private key
const SENDER_PRIVATE_KEY = "<PRIVATE_KEY>";
const buf = Buffer.from(SENDER_PRIVATE_KEY, "base64");
const keyPair = Ed25519Keypair.fromSecretKey(buf.slice(1));

// Or create it from the sender's recovery phrase
const keyPair = Ed25519Keypair.deriveKeypair("<RECOVERY_PHRASE>");

// Create a signer for the sender's keypair
const signer = new RawSigner(keyPair, suiProvider);

The GAS_BUDGET specified here is up to you--as the sponsor--to set. When doing so, remember that:

  1. The budget set for a transaction will be "held" from your funds while the transaction is in flight.
  2. If your budget is set too low, the transaction will fail, but funds will still be deducted for the computation cost of running the failed transaction on the Sui blockchain.

This ends up being an exercise in fine tuning so your budgeted gas is enough to push a transaction through, but not overly provisioned that you are inefficient in your fund usage. To help in this regard we will publish a future guide on gas usage measurements of various Sui transactions.

As mentioned above, we are using the typed-rpc library here to setup the typed request and response structure to/from the Gas Station API endpoints:

// Setup for issuing json rpc calls to the gas station for sponsorship.
interface SponsoredTransaction {
    txBytes: string;
    txDigest: string;
    signature: string;
    expireAtTime: number;
    expireAfterEpoch: number;
}
type SponsoredTransactionStatus = "IN_FLIGHT" | "COMPLETE" | "INVALID";

interface SponsorRpc {
    gas_sponsorTransactionBlock(txBytes: string, sender: string, gasBudget: number): SponsoredTransaction;
    gas_getSponsoredTransactionBlockStatus(txDigest: string): SponsoredTransactionStatus;
}
const sponsor = rpcClient<SponsorRpc>(SPONSOR_RPC_URL);

Our setup is complete, so let's construct some gasless transaction blocks (these do not contain any gas context). Here are some examples of basic operations, and a templated Move call:

// Create a programmable transaction block to send an object from the sender to the recipient
const progTxnTransfer = () => {
    // The receiver of a transaction
    const RECIPIENT_ADDRESS = "<SUI_ADDRESS>";

    // For transferring objects transaction
    const OBJECT_TO_SEND = "<OBJECT_ID>";

    const txb = new TransactionBlock();
    
    txb.transferObjects(
        [txb.object(OBJECT_TO_SEND)],
        txb.pure(RECIPIENT_ADDRESS)
    );
    return txb;
}

// Create a programmable transaction block to split a coin into different amounts
const progTxnSplit = () => {
    const COIN_TO_SPLIT = "<COIN_OBJECT_ID>";

    const txb = new TransactionBlock();
    
    // Split two new coins out of COIN_TO_SPLIT, one with 10000 balance, and the other with 20000
    const [coin1, coin2] = txb.splitCoins(
        txb.object(COIN_TO_SPLIT),
        [txb.pure(10000), txb.pure(20000)]
    );
    // Each new object created in a transaction must have an owner
    txb.transferObjects(
        [coin1, coin2],
        txb.pure(SENDER_ADDRESS)
    );
    return txb;
}

// Create a programmable transaction block to merge two coins into a target coin
const progTxnMerge = () => {
    const COIN_TARGET = "<COIN_OBJECT_ID>";
    const COIN_SOURCE1 = "<COIN_OBJECT_ID>";
    const COIN_SOURCE2 = "<COIN_OBJECT_ID>";
    const txb = new TransactionBlock();
    txb.mergeCoins(txb.object(COIN_TARGET), [txb.object(COIN_SOURCE1), txb.object(COIN_SOURCE2)]);
    return txb;
}

// Create a programmable transaction block to issue a move call
const progTxnMoveCall = () => {
    const txb = new TransactionBlock();
    txb.moveCall({
        target: "<PACKAGE_ADDRESS>::<MODULE_NAME>::<METHOD_NAME>",
        arguments: [
            // these are args custom to your move call
            txb.pure(<PURE_VALUE>),
            txb.pure(<PURE_VALUE>),
            txb.object("<OBJECT_ID>")
            ...
        ]
    });
    return txb;
}

Now that we have our transaction block, let's send it to Shinami's Gas Station for sponsorship. On the backend, Shinami will attach a gas object large enough to meet your specified gas budget request, create a new full transaction payload (with gas context attached), and sign it with the gas owner's signature.

const sponsorTransactionE2E = async() => {
    // Get the gasless TransactionBlock for the desired programmable transaction
    const gaslessTxb = progTxnTransfer();
    //const gaslessTxb = progTxnSplit();
    //const gaslessTxb = progTxnMerge();
    //const gaslessTxb = progTxnMoveCall();

    // generate the bcs serialized transaction data without any gas object data
    const gaslessPayloadBytes = await gaslessTxb.build({ provider: suiProvider, onlyTransactionKind: true});

    // convert the byte array to a base64 encoded string
    const gaslessPayloadBase64 = btoa(
        gaslessPayloadBytes
            .reduce((data, byte) => data + String.fromCharCode(byte), '')
    );

    // Send the gasless programmable payload to Shinami Gas Station for sponsorship, along with the sender and budget
    const sponsoredResponse = await sponsor.gas_sponsorTransactionBlock(gaslessPayloadBase64, SENDER_ADDRESS, GAS_BUDGET);

    // The transaction should be sponsored now, so its status will be "IN_FLIGHT"
    const sponsoredStatus = await sponsor.gas_getSponsoredTransactionBlockStatus(sponsoredResponse.txDigest);
    console.log("Sponsorship Status:", sponsoredStatus);

    ...

📘

Note:

The use of GasCoin as an argument can be a security concern with a Gas Station so we have prohibited its use in any requests for sponsorship.

sponsoredResponse will contain the full transaction payload and the signature of the gas object owner. To send it off for execution on the Sui blockchain, the full transaction payload still needs to be signed by the sender.

        ...

    // Sign the full transaction payload with the sender's key.
    const senderSig = await signer.signTransactionBlock({transactionBlock: TransactionBlock.from(sponsoredResponse.txBytes)});
    
    // If you prefer not to use the sui typescript sdk, the signer also supports signing over the bytes
    //const senderSig = await signer.signTransactionBlock({ transactionBlock: Uint8Array.from(atob(sponsoredResponse.txBytes), c => c.charCodeAt(0)) });

    // Send the full transaction payload, along with the gas owner and sender's signatures for execution on the Sui blockchain
    const executeResponse = await suiProvider.executeTransactionBlock(
        {
            transactionBlock: sponsoredResponse.txBytes,
            signature: [senderSig.signature, sponsoredResponse.signature],
            options: {showEffects: true},
            requestType: 'WaitForLocalExecution'
        }
    );
    console.log("Execution Status:", executeResponse.effects?.status.status);
}

// Run the end-to-end sponsorship and execution of the transaction.
sponsorTransactionE2E();

Once the transaction has been sent off for execution, we can view the status of our sponsored transaction in the Gas Station section of the Shinami dashboard: