Skip to main content

IOTA Owned Object Pools Overview

info

IOTA Owned Object Pools (IOTAOOP) 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 IOTA 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 coin-merging and the IOTA TypeScript SDK's default coin selection logic, which uses all the 0x2::coin::Coin<0x2::iota::IOTA> 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 IOTAOOP 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 IOTAOOP 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 NANOS 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::iota::IOTA> in your wallet for each transaction that you need to execute in parallel (in our case 10 coins). Each Coin<IOTA> should have enough balance to execute each transaction. If you need IOTA for a test network, you can use a faucet to mint some.

import { IotaClient } from '@iota/iota-sdk/client';
import { Ed25519Keypair } from '@iota/iota-sdk/keypairs/ed25519';
import { Transaction } from '@iota/iota-sdk/transactions';
import { fromBase64 } from '@iota/iota-sdk/utils';

/* HERE ARE DEFINED THE PREPARATORY STEPS IF YOU WANT TO CODE ALONG*/
// Define the transaction
function createPaymenttx(recipient: string): Transaction {
const tx = new Transaction();
const [coin] = tx.splitCoins(
tx.gas,
[tx.pure(1000000)], // Amount to be transferred to the recipient
);
tx.transferObjects([coin], tx.pure(recipient));
return tx;
}
// Define your admin keypair and client
const ADMIN_SECRET_KEY: string = '<your-address-secret-key>';
const adminPrivateKeyArray = Uint8Array.from(Array.from(fromBase64(ADMIN_SECRET_KEY)));
const adminKeypair = Ed25519Keypair.fromSecretKey(adminPrivateKeyArray.slice(1));

const client = new IotaClient({
url: process.env.IOTA_NODE!,
});

Now, set up the service handler and execute the transactions defined previously. Use the execute method of the ExecutorServiceHandler class.

import { ExecutorServiceHandler } from 'iotaoop';

// Setup the executor service
const eshandler = await ExecutorServiceHandler.initialize(adminKeypair, client);
// Define the number of transactions to execute
const promises = [];
let tx: TransactionBlockWithLambda;
for (let i = 0; i < 10; i++) {
tx = new TransactionBlockWithLambda(() => ceatePaymenttx('<recipient-address>'));
promises.push(eshandler.execute(tx, client));
}

// Collect the promise results
const results = await Promise.allSettled(promises);

Notice the use of TransactionBlockWithLambda() instead of Transaction(). The TransactionBlockWithLambda function is a more flexible way of defining transactions. What differs is that the transaction will be created later, just before the transaction execution is done by a worker pool.

And that's it! 🚀