Sponsored Transactions (TypeScript)

How to sponsor transactions with Shinami on Sui

Overview

In this tutorial you'll learn how to sponsor transactions with 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 Shinami Clients SDK to make JSON-RPC calls to Shinami Gas Station for transaction sponsorship and Node Service for transaction execution.

Sponsored Transactions

When calling sui_executeTransactionBlock a Base64 encoded string is passed in which encodes the transaction to be executed, the sender address, and the gas information for the transaction block. This unit is called a TransactionData object. Along with one or more signatures, it's what's needed to write to the chain.

When a transaction is being sponsored, the sponsor sends Shinami Gas Station two parts of a TransactionData object: the sender address and the transaction block to be executed with gas information omitted (this is considered a TransactionKind object). Gas Station attaches a GasData object that will pay for the transaction. Then, the BCS-serialized, Base64-encoded string that represents the full TransactionData object is produced and sent back along with Gas Station's signature authorizing this transaction.

From there, the last piece needed to call sui_executeTransactionBlock is the sender's signature over the TransactionData. We'll go through all of this below.

Tutorial: creating, sponsoring, and executing a transaction

Note: this tutorial requires a Shinami account. If you do not have one, you can sign up here.

1. Create a Shinami Gas Station fund on Testnet and deposit SUI

See our product FAQ for guidance on how to set up a fund on Testnet and add free SUI to it if you don't already have one.

2: Create an API access key with Node Service and Gas Station Testnet rights

You use API access keys to interact with Shinami's services. For this tutorial, we'll create one access key that has both Node Service and Gas Station rights for Testnet (you could also make a two keys, one with only Node Service rights and one with only Gas Station rights). See our Authentication and API Keys guide for info on how to set up a key with rights to multiple services.

3. Sponsor a transaction with a newly-generated KeyPair as sender

The below code creates a transaction that calls a function on a Move module we've deployed to Testnet. This is a very simple Move module based on a sample Move project from Mysten. We're calling its one function, access, which takes a read-only reference to the sui::clock::Clock instance located at address 0x6 as its single parameter. The function emits a single event that contains the current timestamp obtained from the Clock instance.

Bonus: if you want to listen to the event emitted by the Move call over a WebSocket connection, see the cURL and TypeScript examples for suix_subscribeEvent in our WebSocket API doc.

Replace all instances of {{name}} with the actual value for that name

// 1. Import everything we'll need for the rest of the tutorial
import { Ed25519Keypair } from "@mysten/sui.js/keypairs/ed25519";
import { TransactionBlock } from "@mysten/sui.js/transactions";
import { GasStationClient, createSuiClient, buildGaslessTransactionBytes } from "@shinami/clients";

// 2. Copy your Testnet Gas Station and Node Service key value
const GAS_AND_NODE_TESTNET_ACCESS_KEY = "{{gasAndNodeServiceTestnetAccessKey}}";

// 3. Set up your Gas Station and Node Service clients
const nodeClient = createSuiClient(GAS_AND_NODE_TESTNET_ACCESS_KEY);
const gasStationClient = new GasStationClient(GAS_AND_NODE_TESTNET_ACCESS_KEY);

// 4. Generate a new KeyPair to represent the sender
let keyPair = new Ed25519Keypair();
const SENDER_ADDRESS = keyPair.toSuiAddress();

// 5. Generate the TransactionKind for sponsorship as a Base64 encoded string
let gaslessPayloadBase64 = await buildGaslessTransactionBytes({
  sui: nodeClient,
  build: async (txb) => {
    txb.moveCall({
      target: "0xfa0e78030bd16672174c2d6cc4cd5d1d1423d03c28a74909b2a148eda8bcca16::clock::access",
      arguments: [txb.object('0x6')]
    });
  }
});

// 6. Send the TransactionKind to Shinami Gas Station for sponsorship.
//     We are omitting the gasBudget parameter to take advantage of auto-budgeting.
let sponsoredResponse = await gasStationClient.sponsorTransactionBlock(
  gaslessPayloadBase64,
  SENDER_ADDRESS
);

// 7. The transaction should be sponsored as long as there was SUI 
//     in your fund, so its status will be "IN_FLIGHT"
let sponsoredStatus = await gasStationClient.getSponsoredTransactionBlockStatus(
  sponsoredResponse.txDigest
);
console.log("Transaction Digest:", sponsoredResponse.txDigest);
  // For me this printed "Transaction Digest: GE6rWNfjVk7GiNSRHExaYnQB6TNKRpWBbQrAAK1Cqax5"
  // which we'll see in the image below. 
console.log("Sponsorship Status:", sponsoredStatus);
  // Printed "Sponsorship Status: IN_FLIGHT"

At this point, if you look in your Shinami dashboard at the In flight transactions tab , you'll see the digest you just printed out (of course, your digest will be different). If you run this code twice, you'll get two digests as a different gas object is being used each time.

In flight transactions will remain here until they are executed or the sponsorship expires before an execution attempt was made (all sponsorships have a 1 hour TTL). After a transaction is executed, the digest can remain in-flight for a few minutes until our Gas Station updates the status of the associated gas objects.

Note: building transaction blocks creates requests to your Node Service account, which counts towards your access key's QPS and your daily Node Service request count (and so your bill if you're on a Growth plan). For more information on how Sui's programmable transaction blocks are built, see our TransactionBlock.build() guide.

4. Sign and execute the transaction

Now, we'll go ahead and execute the transaction we sponsored. The below code continues where we left off:

// 8. Sign the full transaction payload with the sender's key.
let senderSig = await TransactionBlock.from(sponsoredResponse.txBytes).sign({ signer: keyPair });

// 9. Send the full transaction payload, along with the gas owner 
//     and sender's signatures for execution on the Sui network
let executeResponse = await nodeClient.executeTransactionBlock({
  transactionBlock: sponsoredResponse.txBytes,
  signature: [senderSig.signature, sponsoredResponse.signature],
  options: { showEffects: true },
  requestType: "WaitForLocalExecution",
});

console.log("Transaction Digest:", executeResponse.digest);
  // Printed "Transaction Digest: GE6rWNfjVk7GiNSRHExaYnQB6TNKRpWBbQrAAK1Cqax5"
console.log("Execution Status:", executeResponse.effects?.status.status);
  // Printed "Execution Status: success"

The digest printed out will be the same one as what we sponsored, as expected, since the digest is created over the same transaction operations, gas object, and sender address. You can look up the digest in a Sui explorer like Suivision or Suiscan (make sure you've set the explorer to Testnet first). This is an image from Suivision showing that my transaction emitted one TimeEvent as mentioned above:


Appendix

Tips for setting your sponsorship budget

Auto-budgeting

When you omit the gasBudget parameter in a sponsorship request using our Gas Station API or our Invisible Wallet API, we estimate the transaction cost for you. We then add a buffer of 5% for non-shared objects and 25% for shared objects. The larger buffer for shared objects is because in the time between sponsorship and execution, shared objects can change in a way that increases their transaction cost due to transactions from other apps and individuals. Therefore, we encourage you to execute sponsored transactions quickly if possible.

While we believe our buffers work well in most cases, we encourage you to monitor the success rate of your auto-budgeted transactions to gauge whether your specific use-case requires manually setting an even larger gasBudget.

As a part of auto-budgeting, we put your transactionBytes through a sui_dryRunTransactionBlock request as a free service before we attempt to sponsor it. This call will generate error messages for certain invalid transactions, such as if the transactionBytes are transferring an object that's not owned by the sender address you provide. We'll return these errors back to you, which should be the same as if you had made a sui_dryRunTransactionBlock request yourself. We do not do this step if you manually budget, so any issues that would be caught by sui_dryRunTransactionBlock will instead produce an error when you try to execute the transaction.

Manual budgeting

When you provide a value for the gasBudget parameter, remember that:

  1. The budget set for a transaction will be "held" from your gas credits fund while the transaction is in-flight.
  2. If your budget is set too low, the transaction will fail, but gas credits 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 gas credit usage. One place to review the cost of your successful transactions is in a Sui explorer like Suivision or Suiscan.

You can also use sui_devInspectTransactionBlock to estimate the gas cost needed by the transaction, but note that this request can produce additional requests that affect your billing. Also, sui_devInspectTransactionBlock does not catch all transaction execution errors and should not be used for testing the correctness of a transaction. sui_dryRunTransactionBlock is better for testing transaction correctness, but requires an attached gas object and also can produce extra requests that affect your billing.

There's an overview of how gas fees are calculated here if you want more detail.

Check a fund's balance and deposit more SUI in the fund

You can use our gas_getFund endpoint to get the balance for the Gas Station fund tied to the request's API access key. So, in this tutorial you'll be getting the balance of the fund the API key you've been using it tied to.

We generate a deposit address for the fund when you look it up in the Shinami dashboard - go ahead and do that now (see how). If you don't, the depositAddress will be null - which is why we guard against it in the code below.

Replace all instances of {{name}} with the actual value for that name

// -- Check a fund's balance and deposit more SUI in the fund if it's low -- //
let suiCoin = "{{coinObjectId}}";
let coinsToDeposit = [suiCoin];
const SUI_COIN_OWNER_ADDRESS = "{{address}}";
const MIN_BUDGET_MIST = 100_000_000_000;

let { balance, depositAddress }  = await gasStationClient.getFund();

// Deposit address can be null - see our FAQ for how to generate an address
if (depositAddress && balance < MIN_BUDGET_MIST) {
    let gaslessPayloadBase64 = await buildGaslessTransactionBytes({
      sui: nodeClient,
      build: async (txb) => {
        txb.transferObjects(
          coinsToDeposit,
          txb.pure(depositAddress)
        );
      }
    });

    let sponsoredResponse = await gasStationClient.sponsorTransactionBlock(
      gaslessPayloadBase64,
      SUI_COIN_OWNER_ADDRESS
    );

    // Finally, you would:
    //   1. Get the signature from the SUI_COIN_OWNER_ADDRESS, who is the sender 
    //   2. Execute the transaction like above

  }

Other TransactionBlock types

There are more types of programmable transactions than a Move function call like we did above. Below are some additional examples of gas-less transactions you can sponsor and execute.

Notes:

  • They all use the same gaslessPayloadBase64 variable name and can replace step 4 of Section 2 above (if you have them all uncommented at the same time you'll get a compiler error).
  • In a sponsored transaction, you cannot use the gas object provided by Shinami for other purposes. For example, you cannot write const [coin] = txb.splitCoins(txb.gas,[txb.pure(100)]); because it's accessing txb.gas. If you try to sponsor a TransactionKind that uses the gas object you will get an error . The below examples show how to split and transfer coins in a way that doesn't use the gas object.

Replace all instances of {{name}} with the actual value for that name

// Comment out step 5 above:

// 5. Generate the TransactionKind for sponsorship as a Base64 encoded string
//let gaslessPayloadBase64 = await buildGaslessTransactionBytes({
//  sui: nodeClient,
//  build: async (txb) => {
//    txb.moveCall({
//      target: "0xfa0e78030bd16672174c2d6cc4cd5d1d1423d03c28a74909b2a148eda8bcca16::clock::access",
//      arguments: [txb.object('0x6')]
//    });
//  }
//});

// and replace it with one of:

// -- Other TransactionBlock examples  -- //

//  Create two new small coins by taking MIST from a larger one.
//    If you ask for Testnet SUI for an account you have the 
//    private key or passphrase to, you can split two coins from that.
//
//
const COIN_TO_SPLIT = "{{coinObjectId}}";
let gaslessPayloadBase64 = await buildGaslessTransactionBytes({
  sui: nodeClient,
  build: async (txb) => {
    const [coin1, coin2] = txb.splitCoins(txb.object(COIN_TO_SPLIT), [
      txb.pure(10000),
      txb.pure(20000),
    ]);
    // each new object created in a transaction must be sent to an owner
    txb.transferObjects([coin1, coin2], txb.pure(SENDER_ADDRESS));
  }
});



//  Transfer one or more object(s) owned by the sender address to the recipient
//    An easy example is a small coin you created with the above transaction.
//
//
const RECIPIENT_ADDRESS = "{{recipientSuiAddress}}";
const OBJECT_TO_SEND = "{{objectId}}";
let gaslessPayloadBase64 = await buildGaslessTransactionBytes({
  sui: nodeClient,
  build: async (txb) => {
    txb.transferObjects(
      [txb.object(OBJECT_TO_SEND)],
      txb.pure(RECIPIENT_ADDRESS)
    );
  }
});



//  Merge one or more smaller coins into another, destroying the small coin(s)
//    and increasing the value of the large one.
//
//
const TARGET_COIN = "{{targetCoin}}";
const COIN_SOURCE1 = "{{coinSource1}}";
const COIN_SOURCE2 = "{{coinSource2}}";
let gaslessPayloadBase64 = await buildGaslessTransactionBytes({
  sui: nodeClient,
  build: async (txb) => {
    txb.mergeCoins(txb.object(TARGET_COIN), [
      txb.object(COIN_SOURCE1),
      txb.object(COIN_SOURCE2),
    ]);
  }
});

Another way to generate a transaction to sponsor

Above, we used the convenience method buildGaslessTransactionBytes that builds a gas-less transaction, converts it to BCS serialized bytes, and then converts that into the base64 encoded string expected by gasStationClient.sponsorTransactionBlock. Here, we show those three steps broken out into individual pieces:

// Comment out step 5 above:

// 5. Generate the TransactionKind for sponsorship as a Base64 encoded string
//let gaslessPayloadBase64 = await buildGaslessTransactionBytes({
//  sui: nodeClient,
//  build: async (txb) => {
//    txb.moveCall({
//      target: "0xfa0e78030bd16672174c2d6cc4cd5d1d1423d03c28a74909b2a148eda8bcca16::clock::access",
//      arguments: [txb.object('0x6')]
//    });
//  }
//});

// and replace it with:
 
// 4. Generate the TransactionKind for sponsorship as a Base64 encoded string
const txb = new TransactionBlock();
txb.moveCall({
  target: "0xfa0e78030bd16672174c2d6cc4cd5d1d1423d03c28a74909b2a148eda8bcca16::clock::access",
  arguments: [txb.object('0x6')]
});
  
// generate the bcs serialized transaction data without any gas object data
const gaslessPayloadBytes = await txb.build({ client: nodeClient, onlyTransactionKind: true});

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

Generate a KeyPair from a private key or passphrase

If you have the private key or recovery phrase from an existing wallet, you can use those to create the sender's KeyPair. Below is code demonstrating both options.

Replace all instances of {{name}} with the actual value for that name

// -- Generate a KeyPair from a private key or passphrase -- //

// Create the sender's KeyPair from the private key
const SENDER_PRIVATE_KEY = "{{privateKey}}";
const buf = Buffer.from(SENDER_PRIVATE_KEY, "base64");
const keyPairFromSecretKey = Ed25519Keypair.fromSecretKey(buf.slice(1));
console.log(keyPairFromSecretKey.toSuiAddress());


// Create the sender's KeyPair from the recovery phrase
const SENDER_PASSPHRASE = "{{passphrase}}";
const keyPairFromPassphrase = Ed25519Keypair.deriveKeypair(SENDER_PASSPHRASE);
console.log(keyPairFromPassphrase.toSuiAddress());

Integrating Gas Station with Shinami zkLogin wallet API

Shinami's zkLogin Wallet API powers user-controlled wallets that are fully compatible with transactions sponsored by Shinami Gas Station. After generating a zkProof, you can create a gas-less transaction, sponsor it with your Gas Station fund with the zkLogin wallet address as the sender, and sign the transaction with with zkWallet's ephemeral private key. For more information on zkLogin and the order of operations for producing and submitting a transaction to Sui, see Mysten's zkLogin doc. For an end-to-end tutorial showing you how to create your first zkLogin Next.js app that integrates with Shinami's Gas Station API and zkLogin Wallet API, see our zkLogin wallet Next.js Tutorial.


Integrating Gas Station with Shinami Invisible Wallets

Shinami Invisible Wallets are app-controlled wallets you can use to abstract away web3 elements like seed phrases, third-party wallet connections, gas fees, and signing popups. They have native integration with Gas Station, including a method that allow you to sponsor, sign, and execute a transaction in one method call. We've created a tutorial for Invisible Wallets that includes using them with Gas Station.