Sui Owned Object Pools Overview
Sui Owned Object Pools (SuiOOP) is a beta library. Enhancements and changes are likely during development.
Equivocation is a situation where you unintentionally use the same object in more than one transaction and is a common pitfall for builders using owned objects. Implementing horizontal scaling or concurrency for a service that executes transactions on Sui in the natural way results in an architecture that issues multiple transactions in parallel from the same account.
The community largely avoids using owned objects as a result, but doing so means you lose the benefit of the lower latency those objects provide. On top of that, they are impossible to completely avoid because the transaction's gas coin is an owned object.
Finally, the situation is exacerbated by
gas smashing (opens in a new tab) and the Sui TypeScript SDK's
default coin selection logic, which uses all the 0x2::coin::Coin<0x2::sui::SUI>
objects owned by
an address for every transaction's gas payment. These defaults make sending transactions from your
wallet straightforward (doing so automatically cleans up coin dust), but means that developers
writing services need to work against the defaults to maintain distinct gas coins to run
transactions in parallel.
This library addresses these issues, simplifying access to owned objects from backend services that also need to take advantage of concurrency, without equivocating their objects.
The SuiOOP solution
The main modules of the library are executorServiceHandler.ts
and pool.ts
.
executorServiceHandler.ts
contains the logic of the executor service - meaning that it acts like a load balancer, distributing the transactions to the worker pools.pool.ts
contains the logic of the worker pools.
As a user of the library, you use only the executorServiceHandler.ts
module.
The basic concept of SuiOOP is to provide a ExecutorServiceHandler
to use multiple worker pools
contained in a workersQueue
, where each worker executes one of the transactions the user provides
when calling the execute
function.
The flow goes as follows:
First, initialize the ExecutorServiceHandler
containing only one mainPool
. Then, whenever you
submit a transaction to the ExecutorServiceHandler
, it tries to find if there is an available
worker pool to sign and execute the transaction.
The main pool is not a worker pool, meaning that it does not execute transactions. It is only used to store the objects and coins of the account, and to provide them to the worker pools when needed.
If a worker pool is not found, the ExecutorServiceHandler
creates one by splitting the mainPool
.
It does this by taking a part of the objects and coins of the mainPool
and creates a new worker
pool. This is how the ExecutorServiceHandler
scales up.
You can define the split logic by providing a SplitStrategy
object to the ExecutorServiceHandler
on initialization. If you don't provide a splitStrategy
, the DefaultSplitStrategy
is used.
Example code
As an example, assume that you need to execute 10 transactions that transfer 100 MIST each to a fixed recipient.
Before you can run this example, you need to already have at least one coin of type
0x2::coin::Coin<0x2::sui::SUI>
in your wallet for each transaction that you need to execute in
parallel (in our case 10 coins). Each Coin<SUI>
should have enough balance to execute each
transaction. If you need SUI for a test network, you can
use a faucet (opens in a new tab) to mint some.
import { SuiClient } from '@mysten/sui.js/client';
import { Ed25519Keypair } from '@mysten/sui.js/keypairs/ed25519';
import { TransactionBlock } from '@mysten/sui.js/transactions';
import { fromB64 } from '@mysten/sui.js/utils';
/* HERE ARE DEFINED THE PREPARATORY STEPS IF YOU WANT TO CODE ALONG*/
// Define the transaction block
function createPaymentTxb(recipient: string): TransactionBlock {
const txb = new TransactionBlock();
const [coin] = txb.splitCoins(
txb.gas,
[txb.pure(1000000)], // Amount to be transferred to the recipient
);
txb.transferObjects([coin], txb.pure(recipient));
return txb;
}
// Define your admin keypair and client
const ADMIN_SECRET_KEY: string = '<your-address-secret-key>';
const adminPrivateKeyArray = Uint8Array.from(Array.from(fromB64(ADMIN_SECRET_KEY)));
const adminKeypair = Ed25519Keypair.fromSecretKey(adminPrivateKeyArray.slice(1));
const client = new SuiClient({
url: process.env.SUI_NODE!,
});
Now, set up the service handler and execute the transactions defined previously. Use the execute
method of the ExecutorServiceHandler
class.
import { ExecutorServiceHandler } from 'suioop';
// Setup the executor service
const eshandler = await ExecutorServiceHandler.initialize(adminKeypair, client);
// Define the number of transactions to execute
const promises = [];
let txb: TransactionBlockWithLambda;
for (let i = 0; i < 10; i++) {
txb = new TransactionBlockWithLambda(() => ceatePaymentTxb('<recipient-address>'));
promises.push(eshandler.execute(txb, client));
}
// Collect the promise results
const results = await Promise.allSettled(promises);
Notice the use of TransactionBlockWithLambda()
instead of TransactionBlock()
. The
TransactionBlockWithLambda
function is a more flexible way of defining transaction blocks. What
differs is that the transaction block will be created later, just before the transaction execution
is done by a worker pool.
And that's it! 🚀
Processing flow
The overall processing flow is depicted in the following flowchart: