Building Blocks from Scratch

By AnchorFactory ·

The high-level client.send() is great for simple transfers. But anchors and advanced services need full control — multi-operation blocks, chaining blocks in sequence, and publishing atomic staples across accounts.

This recipe covers the low-level Block Builder API.


When to use this

  • Your block needs multiple operations (e.g. SEND + RECEIVE in one block)

  • You need to chain blocks (each block references the previous one’s hash)

  • You’re building blocks for a different account than the signer (e.g. token account blocks signed by the pool owner)

  • You need atomic multi-block publishes (all-or-nothing staples)

For simple “send X tokens to Y”, use client.send() instead.


Core pattern

Every block needs four things: an account (whose chain this block lives on), a previous hash (chain head), a network ID, and one or more operations.

const KeetaNet = require("@keetanetwork/keetanet-client");

const seed = Buffer.from(process.env.SEED, "hex");
const account = KeetaNet.lib.Account.fromSeed(seed, 0);

// 1. Connect and get the chain head
const client = KeetaNet.UserClient.fromNetwork("test", account);
const head = await client.head();

// 2. Build the block
const block = await new KeetaNet.lib.Block.Builder({
  version: 2,
  account: account,
  signer: account,
  network: 0x54455354,  // testnet
  previous: head ?? KeetaNet.lib.Block.NO_PREVIOUS,
  operations: [
    {
      type: KeetaNet.lib.Block.Builder.OperationType.SEND,
      to: KeetaNet.lib.Account.fromPublicKeyString(recipientAddress),
      token: KeetaNet.lib.Account.fromPublicKeyString(tokenAddress),
      amount: 1000000000n,  // 1 token (9 decimals on testnet)
    }
  ]
}).seal();

// 3. Publish with fee block
await client.client.transmit([block], {
  generateFeeBlock: async (staple) => {
    const feeBuilder = client.initBuilder();
    return await feeBuilder.computeFeeBlock(staple);
  }
});

Operation types

SEND

Transfer tokens from this account to a recipient.

{
  type: KeetaNet.lib.Block.Builder.OperationType.SEND,
  to: recipientAccount,       // Account object
  token: tokenAccount,        // Token Account object
  amount: 500000000n,         // BigInt — raw amount, no decimals
  external: "my-ref-123"      // optional — metadata tag visible on-chain
}

RECEIVE

Accept incoming tokens. Used in swap flows where both sides need to agree.

{
  type: KeetaNet.lib.Block.Builder.OperationType.RECEIVE,
  from: senderAccount,        // who sent the tokens
  token: tokenAccount,
  amount: 500000000n,
  exact: false,               // false = "at least this amount"
  forward: null               // optional — forward to another account
}

SET_INFO

Set name, description, and metadata on an account.

{
  type: KeetaNet.lib.Block.Builder.OperationType.SET_INFO,
  name: "My Service",
  description: "An anchor service on Keeta",
  metadata: btoa(JSON.stringify({ decimalPlaces: 9 }))  // base64 JSON
}

TOKEN_ADMIN_SUPPLY (mint / burn)

Adjust the supply of a token you administer.

{
  type: KeetaNet.lib.Block.Builder.OperationType.TOKEN_ADMIN_SUPPLY,
  amount: 1000000000000000000n,
  method: KeetaNet.lib.Block.Builder.AdjustMethod.ADD  // ADD = mint, SUBTRACT = burn
}

CREATE_IDENTIFIER

Generate a new token address from a parent account.

{
  type: KeetaNet.lib.Block.Builder.OperationType.CREATE_IDENTIFIER,
  index: 0   // unique index per parent account
}

Full example: send tokens

Here’s a complete, runnable script that sends tokens from one account to another on testnet.

const KeetaNet = require("@keetanetwork/keetanet-client");

async function sendTokens() {
  // Your account (derived from seed)
  const seed = Buffer.from(process.env.SEED, "hex");
  const sender = KeetaNet.lib.Account.fromSeed(seed, 0);

  // Recipient (public key only — no private key needed)
  const recipient = KeetaNet.lib.Account.fromPublicKeyString(
    "keeta_aab..."  // replace with actual address
  );

  // Token to send
  const token = KeetaNet.lib.Account.fromPublicKeyString(
    "keeta_anyiff4v3..."  // KTA on testnet, or any token address
  );

  // Connect to testnet
  const client = KeetaNet.UserClient.fromNetwork("test", sender);

  // Get the sender's chain head (latest block hash)
  const head = await client.head();

  // Build the send block
  const block = await new KeetaNet.lib.Block.Builder({
    version: 2,
    account: sender,
    signer: sender,
    network: 0x54455354,  // testnet network ID
    previous: head ?? KeetaNet.lib.Block.NO_PREVIOUS,
    operations: [
      {
        type: KeetaNet.lib.Block.Builder.OperationType.SEND,
        to: recipient,
        token: token,
        amount: 5000000000n,  // 5 tokens (9 decimals)
      }
    ]
  }).seal();

  // Publish — includes automatic fee block generation
  await client.client.transmit([block], {
    generateFeeBlock: async (staple) => {
      const feeBuilder = client.initBuilder();
      return await feeBuilder.computeFeeBlock(staple);
    }
  });

  console.log("Sent! Block hash:", block.hash);
}

sendTokens().catch(console.error);

Multi-operation blocks

A single block can contain multiple operations. This is common in swap flows where a pool sends tokens out and receives tokens in:

const operations = [
  {
    type: KeetaNet.lib.Block.Builder.OperationType.SEND,
    to: traderAccount,
    token: outputToken,
    amount: outputAmount,
  },
  {
    type: KeetaNet.lib.Block.Builder.OperationType.RECEIVE,
    from: traderAccount,
    token: inputToken,
    amount: inputAmount,
    exact: false,
  }
];

const block = await new KeetaNet.lib.Block.Builder({
  version: 2,
  account: poolAccount,
  signer: poolAccount,
  network: 0x54455354,
  previous: head,
  operations
}).seal();

Chaining blocks

When you publish multiple blocks for the same account, each block must reference the previous block’s hash — not the original chain head.

// First block uses the chain head
const block1 = await new KeetaNet.lib.Block.Builder({
  version: 2,
  account: poolAccount,
  signer: poolAccount,
  network: 0x54455354,
  previous: head,
  operations: [/* ... */]
}).seal();

// Second block chains off the first
const block2 = await new KeetaNet.lib.Block.Builder({
  version: 2,
  account: poolAccount,
  signer: poolAccount,
  network: 0x54455354,
  previous: block1.hash,  // chain from block1, not from head
  operations: [/* ... */]
}).seal();

// Publish both atomically
await client.client.transmit([block1, block2], {
  generateFeeBlock: async (staple) => {
    const feeBuilder = client.initBuilder();
    return await feeBuilder.computeFeeBlock(staple);
  }
});

Signing on behalf of another account

Token accounts don’t have their own private key — they’re administered by a parent account. When building blocks on a token’s chain, set account to the token and signer to the admin:

const tokenAccount = KeetaNet.lib.Account.fromPublicKeyString(tokenAddress);
const adminAccount = KeetaNet.lib.Account.fromSeed(seed, 0);

// Get the token's chain head (need admin as signer for access)
const tokenClient = KeetaNet.UserClient.fromNetwork("test", adminAccount, {
  account: tokenAccount
});
const tokenHead = await tokenClient.head();

const mintBlock = await new KeetaNet.lib.Block.Builder({
  version: 2,
  account: tokenAccount,   // block lives on the token's chain
  signer: adminAccount,    // signed by the admin
  network: 0x54455354,
  previous: tokenHead ?? KeetaNet.lib.Block.NO_PREVIOUS,
  operations: [{
    type: KeetaNet.lib.Block.Builder.OperationType.TOKEN_ADMIN_SUPPLY,
    amount: 1000000000000000000n,
    method: KeetaNet.lib.Block.Builder.AdjustMethod.ADD
  }]
}).seal();

Fee blocks

Every publish requires a fee block — a small KTA payment for network validation. The SDK handles fee calculation automatically:

await client.client.transmit(blocks, {
  generateFeeBlock: async (staple) => {
    // feeAccount pays the fee (must have KTA balance)
    const feeClient = KeetaNet.UserClient.fromNetwork("test", feeAccount);
    const feeBuilder = feeClient.initBuilder();
    return await feeBuilder.computeFeeBlock(staple);
  }
});

The fee account can be different from the block’s account — useful when a service pays fees on behalf of users.


Account helpers

// From a seed (has private key — can sign)
const account = KeetaNet.lib.Account.fromSeed(seed, index);

// From a public key string (no private key — read-only)
const account = KeetaNet.lib.Account.fromPublicKeyString("keeta_aab...");

// Get the address string
const address = account.publicKeyString.get();  // NOT .toString()

Common patterns

Pattern

Operations

Use Case

Simple send

1x SEND

Token transfer

Swap

1x SEND + 1x RECEIVE

DEX trade (one side)

Token creation

CREATE_IDENTIFIER + SET_INFO + MINT + SEND(s)

New token launch

Fee sweep

SEND (from fee account)

Collect accumulated fees

Metadata update

SET_INFO

Update service registration

More in Core SDK · All documentation