Invisible Wallet TypeScript Tutorial

How to utilize the Shinami Invisible Wallet

Overview

In this tutorial, we explain how to use the Shinami Invisible Wallet API. Shinami’s Invisible Wallets are app-controlled, backend wallets under the shared custody of your app and Shinami. Both parties must cooperate in order to obtain a valid signature. We also offer user-controlled zkLogin wallet services. Both can be "invisible" from the perspective of the end-user, who can benefit from Web3 without the burden of wallet management.

The examples below show how to create new wallets, and how to sign and execute transactions using those wallets. They also show how to leverage Shinami's Gas Station to seamlessly sponsor, sign and execute a transaction in one call. We'll be constructing programmable transactions using the Sui TypeScript SDK provided by Mysten. We'll also use the Shinami Clients SDK to make JSON-RPC calls to create and use Invisible Wallets.

When you create an Invisible Wallet for a user, it won't have any Sui in it. This is why Invisible Wallets pair so well with Shinami Gas Station: you can initiate transactions on behalf of your users' wallets without them having to go buy Sui and transfer it to their wallet address. Removing this friction - along with the burden of remembering recovery phrases and reading signing pop-ups - is a great way to smoothly onboard web2-native users.

The full code for this TypeScript tutorial is available on Github.

Tutorial

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

1: Set up a Shinami gas station fund on Testnet

See our Gas Station Guide for how to set up a fund on Testnet and add free Sui to it if you don't have one.

2: Set up an access key

You use API access keys to interact with Shinami's services. You create them in your Shinami Dashboard. For this tutorial, we'll create one access key with rights for all Testnet services - Node Service, Gas Station, and Wallet Services - because one of the calls requires rights to all services. Some calls require rights to just one service, so you could create three additional keys if desired - one for each service. For this simple Testnet tutorial we'll just use the one key in all cases.

When you create a key with Node Service rights, you assign a max QPS and max number of WebSocket subscriptions the key is allowed. You can change a key's values later by clicking on the + next to the key in Access Keys table to open the key editor. When you create an access key with Gas Station rights, you link it to a Gas Station fund. All requests for sponsorship using the key draw Sui from that fund. So, you'll link this key to the gas station Testnet fund you created in step one. Wallet Service keys are actually network agnostic as one key works across all networks. For example:

3: Create two invisible wallets

Shinami Invisible Wallets are created and used only through API calls from your backend to our Wallet Service (i.e., there is no way to create one in the Shinami dashboard UI). Creating them is easy, though, and we show you how below. First, an important note:

An important note on WalletId and secret pairing

For each invisible wallet you create, you also need to create and store two new pieces of information: a unique walletId and its associated, ideally unique secret. This is a 1-to-1 pairing and can be thought of as a username/password equivalent for the wallet being created. A walletId only works with the secret used when the wallet was created. Your application MUST remember each (walletId, secret) pair. If you forget or change either value, the wallet's private key will be unrecoverable. For more information and images, see WalletId and Secret Pairing.

Create a wallets

Let's get started by creating a wallet. You'll need to define a (walletId, secret) pair first. Below, we use ShinamiWalletSigner to simplify things by abstracting away session token management.

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 { 
  WalletClient, 
  KeyClient, 
  createSuiClient, 
  GasStationClient,
  buildGaslessTransactionBytes, 
  ShinamiWalletSigner
} from "@shinami/clients";
import { TransactionBlock } from "@mysten/sui.js/transactions";
import { verifyPersonalMessage } from '@mysten/sui.js/verify';

// 2. Copy your access key value
const ALL_SERVICES_TESTNET_ACCESS_KEY = "{{allServicesTestnetAccessKey}}";

// 3. Set up a wallet id and an associated sercret
const WALLET_ONE_ID = "{{walletOneId}}";
const WALLET_ONE_SECRET = "{{walletOneSecret}}";


// 4. Instantiate your Shinami clients
const keyClient = new KeyClient(ALL_SERVICES_TESTNET_ACCESS_KEY);
const walletClient = new WalletClient(ALL_SERVICES_TESTNET_ACCESS_KEY);
const nodeClient = createSuiClient(ALL_SERVICES_TESTNET_ACCESS_KEY);

// 5. Set up a variable we'll use in the examples below
let senderSignature = null;

// 6. Generate a signer for our invisible wallet and try to create it
const signer = new ShinamiWalletSigner(
  WALLET_ONE_ID,
  walletClient,
  WALLET_ONE_SECRET,
  keyClient
);

// 7. Returns the Sui address of the invisible wallet, 
//     creating it if it hasn't been created yet
let WALLET_ONE_SUI_ADDRESS = await signer.getAddress(true);
console.log("Invisible wallet Sui address:", WALLET_ONE_SUI_ADDRESS);

4: Sponsor, sign and execute a transaction in one JSON-RPC call

Below, we use the Invisible Wallet API's method to sponsor, sign, and execute a transaction in one call. This method is why we needed an access key to rights with all services, as it uses Gas Station to sponsor the transaction and produce the sponsor's signature, wallet service to sign the sponsored transaction as the sender, and node service to execute the transaction.

The below code picks up where we left off above. It 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 it's one function, access, which takes a read-only reference of 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 call you make here over a WebSocket connection, see the cURL and TypeScript examples for suix_subscribeEvent in our WebSocket API doc.

// 8. 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')]
  });
}
});


// 9. Sponsor, sign, and execute the transaction
//    We are omitting the gasBudget parameter to take advantage of auto-budgeting.
const sponsorSignAndExecuteResponse = await signer.executeGaslessTransactionBlock(
  gaslessPayloadBase64,
  undefined,
  { showEffects: true },
  "WaitForLocalExecution"
)

// You can look up the digest in Sui Explorer - make sure to switch to Testnet
console.log("sponsorSignAndExecuteResponse.digest:", sponsorSignAndExecuteResponse.digest);
// Example output: "8CjRvpcSQpDHAJQiUQxpXtkapxdMXQuDCReqxquQZevJ"
//  which I search for in the images below.

You can look up the digest in Sui Explorer. Make sure you're viewing Testnet when you search as that's where we're executing our transactions:

Here's a search from one of my tests. You can see that it called the "access" function on the "clock" module from the Move package we targeted above.

5: Sponsor, sign, and execute a transaction in three JSON-RPC calls

Now, we'll do the same thing with three calls. Using three calls is more work, but gives you greater flexibility if you need it. We'll use the second wallet we created for this transaction.

Note: When you use Gas Station to sponsor a transaction, the sponsorship has a 1 hour TTL, so you have up to one hour to execute a sponsored transaction. The associated Sui will be locked up in your fund until the transaction is executed or one hour passes without execution. To learn more about sponsorship including our auto-budgeting feature, see the Gas Station API doc.

// -- Sponsor, sign, and execute a transaction in three calls -- //

const gasStationClient = new GasStationClient(ALL_SERVICES_TESTNET_ACCESS_KEY); 

// Sponsor the above transaction with a call to Gas Station.
// We are omitting the gasBudget parameter to take advantage of auto-budgeting.
const sponsoredResponse = await gasStationClient.sponsorTransactionBlock(
  gaslessPayloadBase64,
  WALLET_ONE_SUI_ADDRESS
);

// Sign the transaction (the Invisible Wallet is the sender)
senderSignature = await signer.signTransactionBlock(
  sponsoredResponse.txBytes
);

// Use the TransactionBlock and sponsor signature produced by 
//  `sponsorTransactionBlock` along with the sender's signature we 
//  just obtained to execute the transaction.
const executeSponsoredTxResponse = await nodeClient.executeTransactionBlock({
  transactionBlock: sponsoredResponse.txBytes,
  signature: [senderSignature.signature, sponsoredResponse.signature],
  options: { showEffects: true },
  requestType: "WaitForLocalExecution",
});

console.log("executeSponsoredTxResponse.digest: ", executeSponsoredTxResponse.digest);

6: Sign and execute a non-sponsored transaction

Finally, we'll be submitting a programmable transaction on behalf of the wallet, splitting off two coins with values of 10,000 and 20,000 MIST respectively from a main SUI coin, and transferring them back to the wallet address. See the sponsored transaction tutorial for examples of other types of transactions.

In this case, the Invisible Wallet we're using will need some Testnet Sui to pay for its transaction. You can request some and transfer it to the wallet address in the same manner you did when setting up a Gas Station Fund. You'll need to make two transfers:

  • A 1 Sui coin that we'll split into smaller coins.
  • A 1 Sui coin that will be used to pay for the gas.

Once I transfer the Sui to the first Invisible Wallet address I created, I can look see in the Sui Explorer that it now owns two coin objects:

When I click on the object id of first coin, it takes me to a page where I can copy the object id.

This is what I'll paste as the value for COIN_TO_SPLIT in the sample code below. To run this section you'll first need to un-comment the code.

📘

Note:

For the non-sponsorship case, you will need to provide your own gas data and build it with onlyTransactionKind set to false or omitted (the default). A value of true builds a TransactionKind, which is a transaction with gas data and sender omitted (what you send to Gas Station for sponsorship).

// -- Sign and execute a non-sponsored transaction -- //

// You can send Testnet Sui to your wallet address 
//  via the Sui Discord Testnet faucet

// Set this to the id of a Sui coin owned by the sender address
const COIN_TO_SPLIT = "{{coinToSplitObjectId}}";

// Create  new TransactionBlock and add the operations to create
//  two new coins from MIST contained by the COIN_TO_SPLIT
const txb = new TransactionBlock();
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(WALLET_ONE_SUI_ADDRESS));
  // Set gas context and sender
txb.setSender(WALLET_ONE_SUI_ADDRESS);
txb.setGasBudget(GAS_BUDGET);
txb.setGasOwner(WALLET_ONE_SUI_ADDRESS);


// Generate the bcs serialized transaction data with gas data by setting onlyTransactionKind to false
const txBytes = await txb.build({ client: nodeClient, onlyTransactionKind: false});

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

// Sign the transaction (the Invisible Wallet is the sender)
senderSignature = await signer.signTransactionBlock(
  txBytesBase64
);

// Execute the transaction 
const executeNonSponsoredTxResponse = await nodeClient.executeTransactionBlock({
  transactionBlock: txBytesBase64,
  signature: [senderSignature.signature],
  options: { showEffects: true },
  requestType: "WaitForLocalExecution",
});

console.log("executeNonSponsoredTxResponse.digest:", executeNonSponsoredTxResponse.digest);

Now, I can see that I have four coins in the wallet. I have the two original ones, which are slightly smaller because one paid a gas fee and we took MIST from the other two make two new coins. I also have the two new, small coins I created:

Conclusion

As you can see, with Shinami's Invisible Wallet you have flexibility on how to integrate for your needs. On a fundamental level, you can use it as a managed wallet for users with a low level API to securely sign transactions. Alternatively, if you plan on also using Shinami's Gas Station Service, you can leverage our high level API to sponsor, sign, and execute a transaction in one convenient call.

We hope this tutorial has been helpful. Let us know if you have any feedback by shooting us an email at [email protected].

Appendix: Sign and verify a personal message

To prove ownership of a wallet, you can use Wallet Service's method to sign a personal message. The below also uses a method from the Mysten TypeScript SDK to verify whether the signature was from the expected address.

Note: for compatibility with the Mysten SDK, the wrapBcs parameter needs to be set to true. When using the Shinami Clients SDK, it's set to true by default.

//  -- Sign a personal message from an invisible wallet and then verify the signer -- //

// Encode the as a base64 string
let message = "I have the private keys."; 
let messageAsBase64String = btoa(message);

// Sign the message with the Invisible Wallet
let signature = await signer.signPersonalMessage(
  messageAsBase64String
);

// When we check the signature, we encode the message as a byte array
// and not a base64 string like when we signed it
let messageBytes = new TextEncoder().encode(message); 

// Failure throws a `Signature is not valid for the provided message` Error
let publicKey = await verifyPersonalMessage(messageBytes, signature);

// Check that the signer's address matches the Invisible wallet's address
console.log("Personal message signer address matches Invisible Wallet address:", WALLET_ONE_SUI_ADDRESS == publicKey.toSuiAddress());
// true