Invisible Wallets (TypeScript)

How to utilize the Shinami Invisible Wallet

Overview

In this tutorial, we show you 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 benefits from Web3 ownership without the burden of wallet management.

The examples below show how to create a new wallet and then sign and execute transactions using that wallet. 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.

The examples also show how to leverage Shinami's Gas Station to seamlessly sponsor, sign and execute a transaction in one request. When you create an Invisible Wallet for a user it won't have any SUI in it. With Gas Station you can sponsor your user's transaction fees so they don't have to download a wallet app and complete KYC to buy SUI. 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: 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 Testnet rights to all Shinami services

You use API access keys to interact with Shinami's services. 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 simplicity, in this tutorial we'll just use one key in all cases. See our Authentication and API Keys guide for info on how to set up a key with rights to all services.

3: Create an invisible wallet

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. But first:

An important note on WalletId and secret pairing

For each Invisible Wallet you create, you need to create and store two new pieces of information: a unique walletId and its associated, ideally unique secret. This 1-to-1 pairing can be thought of as a username/password equivalent you use with Shinami's API for each wallet. A walletId only works with the secret used when the wallet was created, so 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 wallet

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 secret
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 two examples below
let senderSignature = null;

// 6. Create a signer for the Invisible Wallet
const signer = new ShinamiWalletSigner(
  WALLET_ONE_ID,
  walletClient,
  WALLET_ONE_SECRET,
  keyClient
);

// 7. Create the wallet. This request returns the Sui address of an 
//     Invisible Wallet, creating it if it hasn't been created yet
const CREATE_WALLET_IF_NOT_FOUND = true;
let WALLET_ONE_SUI_ADDRESS = await signer.getAddress(CREATE_WALLET_IF_NOT_FOUND);
console.log("Invisible wallet Sui address:", WALLET_ONE_SUI_ADDRESS);

You'll use the WALLET_ONE_SUI_ADDRESS value in the code below and also to look up your wallet in a Sui explorer like Suivision or Suiscan (make sure the explorer is set to Testnet first).

4: Sponsor, sign and execute a transaction in one request

This method is why we needed an API 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 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 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, // setting gasBudget to undefined triggers our auto-budgeting feature
  { showEffects: true },
  "WaitForLocalExecution"
)

// You can look up the digest in a 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.

If you want to learn more about using auto-budgeting or setting a gasBudget manually, see our Gas Station Tutorial's section on the topic.

You can look up the transaction digest printed to the console in a Sui explorer like Suivision or Suiscan (make sure the explorer is set to Testnet first). Make sure you're viewing Testnet when you search as that's where we're executing our transactions.

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.

5: Sponsor, sign, and execute a transaction in three requests

Now, we'll do the same thing with three requests. That's more work, but it gives you greater flexibility if you need it.

Note: When you use Gas Station to sponsor a transaction, the sponsorship has a 1 hour TTL. The associated SUI will be locked up in your fund until the transaction is executed or one hour passes without execution.

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

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 non-sponsored transaction on behalf of the wallet, splitting off two coins with values of 10,000 and 20,000 MIST from a main SUI coin, and transferring them back to the wallet address. See our Gas Station 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 the 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 Invisible Wallet address I created, I can use a Sui explorer like Suivision or Suiscan to lookup my wallet address. From there, I can find my SUI coins and copy the object id of one to use in the example below (make sure you've set the explorer to Testnet before searching for your wallet address):

This coin's object id 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. But first, two:

Important notes

  • You cannot use the gas object in a sponsored transaction 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. This is why in the example below we make sure the wallet has a coin in it that can be used for gas (by calling txb.setGasOwner(ownerAddress) it's set automatically).
  • For this non-sponsorship case, you'll set onlyTransactionKind to false when you build the transaction, since gas and sender data is included (unlike in the sponsorship case).
// -- 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}}";
const GAS_BUDGET = 10_000_000; // 10 Million MIST, or 0.01 SUI

// Create a 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);

Refresh the Sui explorer you used and check your wallet's contents again. You should see the two original coins, which are slightly smaller because one paid a gas fee and we took MIST from the other two make two new coins. You should also see the two new, small coins you created.

Conclusion

As you can see, with Shinami's Invisible Wallet you have flexibility on how to integrate for your needs. We hope this tutorial has been helpful. Let us know if you have any issues or 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 control the private key."; 
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