XChain Platform SDK: Batch Builder

The BatchBuilder is a fluent API for composing multiple XChain actions into a single BATCH transaction. It validates each sub-action individually, enforces the BATCH protocol constraints, and produces a ready-to-encode result.


Overview

A BATCH transaction lets you include multiple independent XChain actions in one on-chain transaction. Each sub-action is fully validated and serialized, then the results are joined with ; separators and wrapped in the BATCH action format. From the protocol’s perspective, a BATCH is just an action whose COMMAND field is a semicolon-delimited string of fully-formed action strings.

The BatchBuilder handles all of this automatically:

  • Validates each sub-action through the full pipeline (field validation + format selection + serialization)
  • Enforces BATCH protocol constraints before building
  • Returns the same ActionResult shape as sdk.createAction()

Quick Start

let result = await sdk.batch()
    .send({ tick: 'BTC.TOKEN', amount: 100, destination: 'addr1...' })
    .send({ tick: 'BTC.TOKEN', amount: 50,  destination: 'addr2...' })
    .build();

console.log(result.actionString);
// BATCH|0|SEND|0|BTC.TOKEN|100|addr1...;SEND|0|BTC.TOKEN|50|addr2...

API Reference

sdk.batch()

Returns a new BatchBuilder instance bound to the current SDK instance.

let builder = sdk.batch();

.add(action, params)

Add any action by name. action is case-insensitive. Returns the builder for chaining.

builder.add('SEND', { tick: 'BTC.TOKEN', amount: 10, destination: 'addr...' });
builder.add('MINT', { tick: 'BTC.TOKEN', amount: 1000 });

Convenience Methods

The following convenience methods are shorthand for .add(action, params). All return the builder for chaining. There are 21 convenience methods; .batch() and .deploy() are intentionally omitted because nested BATCH and DEPLOY actions are not permitted inside a BATCH (see Constraints below). FILE is permitted (at most one per BATCH), so .file() is included.

Method Equivalent
.send(params) .add('SEND', params)
.issue(params) .add('ISSUE', params)
.mint(params) .add('MINT', params)
.destroy(params) .add('DESTROY', params)
.order(params) .add('ORDER', params)
.broadcast(params) .add('BROADCAST', params)
.dispenser(params) .add('DISPENSER', params)
.dividend(params) .add('DIVIDEND', params)
.sweep(params) .add('SWEEP', params)
.swap(params) .add('SWAP', params)
.callback(params) .add('CALLBACK', params)
.sleep(params) .add('SLEEP', params)
.airdrop(params) .add('AIRDROP', params)
.message(params) .add('MESSAGE', params)
.list(params) .add('LIST', params)
.link(params) .add('LINK', params)
.address(params) .add('ADDRESS', params)
.execute(params) .add('EXECUTE', params)
.deposit(params) .add('DEPOSIT', params)
.withdraw(params) .add('WITHDRAW', params)
.file(params) .add('FILE', params)

.build(encoderOpts?)

Validates all constraints, builds each sub-action, and returns the complete BATCH ActionResult.

  • Runs _validate() to enforce BATCH protocol constraints
  • Calls sdk.actions.createAction() for each sub-action (full validate + format select + serialize pipeline)
  • Joins all sub-action strings with ;
  • Calls sdk.createAction({ action: 'BATCH', params: { command }, encoder: encoderOpts }) to produce the final result

If encoderOpts is provided (e.g. { pubkey: '...', utxos: [...] }), the SDK will submit the BATCH to the encoder and return a PSBT. Without encoderOpts, the result contains only the serialized action string.

// Serialize only
let result = await builder.build();

// Generate a PSBT via the encoder
let result = await builder.build({
    pubkey: '03abc...',
    utxos: [{ txid: '...', vout: 0, value: 100000 }]
});

.reset()

Clears all queued actions and returns the builder for reuse. Does not affect the bound SDK instance.

builder
    .send({ tick: 'BTC.TOKEN', amount: 10, destination: 'addr1...' })
    .build();

// Reuse the same builder
builder
    .reset()
    .mint({ tick: 'BTC.TOKEN', amount: 500 })
    .build();

.length

Read-only property. Returns the number of actions currently queued in the builder.

let builder = sdk.batch().send({...}).mint({...});
console.log(builder.length); // 2

Constraints

The BATCH protocol enforces the following rules. Violations throw SDKValidationError with code BATCH_CONSTRAINT (or BATCH_EMPTY):

Constraint Error code Notes
At least one action required BATCH_EMPTY Calling .build() on an empty builder
No nested BATCH actions BATCH_CONSTRAINT BATCH inside BATCH is not allowed by the protocol
No DEPLOY actions BATCH_CONSTRAINT DEPLOY payloads are too large for BATCH
At most 1 FILE action BATCH_CONSTRAINT One rawData payload per transaction; details.count contains the actual count
At most 1 MINT action BATCH_CONSTRAINT details.count contains the actual count
At most 1 ISSUE action BATCH_CONSTRAINT details.count contains the actual count

All sub-actions are also fully validated by the Validator before the BATCH is built. A bad field value in any sub-action will throw the corresponding SDKValidationError before .build() returns.


Examples

Multi-Send (send to two addresses)

let result = await sdk.batch()
    .send({ tick: 'BTC.TOKEN', amount: 100, destination: 'addr1...' })
    .send({ tick: 'BTC.TOKEN', amount: 200, destination: 'addr2...' })
    .build();

Issue + Mint in One Transaction

let result = await sdk.batch()
    .issue({
        tick: 'MY.TOKEN',
        maxSupply: 1000000,
        decimals: 8
    })
    .mint({
        tick: 'MY.TOKEN',
        amount: 10000
    })
    .build();

Error Handling: Constraint Violation

const { SDKValidationError } = require('@xchain/sdk/src/errors');

try {
    await sdk.batch()
        .mint({ tick: 'BTC.TOKEN', amount: 100 })
        .mint({ tick: 'BTC.TOKEN', amount: 200 }) // second MINT (violates constraint)
        .build();

} catch (err) {
    if (err instanceof SDKValidationError && err.code === 'BATCH_CONSTRAINT') {
        console.error('Batch constraint violated:', err.message);
        // "BATCH can contain at most 1 MINT action"
        console.error('Actual count:', err.details.count); // 2
    }
}

Gated Content: FILE + MESSAGE in One Transaction

A common pattern for publishing token-gated content is to include a FILE and a MESSAGE to self in the same BATCH. The FILE records the content and its gate ticker on-chain; the MESSAGE records the encrypted decryption key so the issuer can recover it later. Both are committed atomically.

const { encryptedData, encryptedKey, keyHash } = await encryptForGate(rawFileBuffer, 'MYTOKEN');

let result = await sdk.batch()
    .file({
        name:             'exclusive.pdf',
        type:             'application/pdf',
        title:            'Exclusive Drop',
        gateTicker:       'MYTOKEN',
        encryptionMethod: 1,       // AES-256-GCM
        keyHash:          keyHash, // sha256(key), 64 hex chars
        rawData:          encryptedData  // hoisted to encoder opts by build()
    })
    .message({
        destination:      myAddress,       // message to self
        encryptedMessage: encryptedKey     // encrypted key, recoverable by the issuer
    })
    .build({ pubkey: '03abc...', utxos: [...] });

The rawData on the FILE params is automatically extracted and hoisted to the encoder, so it is not necessary to pass it separately to .build().


Builder Reuse with reset()

let builder = sdk.batch();

// First batch
let result1 = await builder
    .send({ tick: 'BTC.TOKEN', amount: 50, destination: 'addr1...' })
    .build();

// Reset and reuse
let result2 = await builder
    .reset()
    .send({ tick: 'LTC.TOKEN', amount: 75, destination: 'addr2...' })
    .build();

With Encoder Options (generate PSBT)

let result = await sdk.batch()
    .send({ tick: 'BTC.TOKEN', amount: 100, destination: 'addr1...' })
    .destroy({ tick: 'BTC.TOKEN', amount: 10 })
    .build({
        pubkey: '03abcdef...',
        utxos: [
            { txid: 'aaabbb...', vout: 0, value: 546 },
            { txid: 'cccddd...', vout: 1, value: 100000 }
        ]
    });

console.log(result.psbt); // base64-encoded PSBT ready to sign

FILE rawData Promotion

When a FILE sub-action carries rawData in its params (for publishing gated or public file content), .build() automatically strips rawData from the FILE params and hoists it to the BATCH-level encoder options. This is because rawData is an encoder-level attachment (it becomes the file payload embedded in the transaction), not part of the action string.

The promotion is transparent: if you also pass encoderOpts to .build(), the extracted rawData is merged in only if encoderOpts.rawData is not already set (explicit caller value takes precedence).

// rawData is extracted from FILE params and hoisted to encoder opts automatically
let result = await sdk.batch()
    .file({ name: 'report.pdf', type: 'application/pdf', title: 'Q1 Report' }, { rawData: pdfBuffer })
    .build({ pubkey: '03abc...', utxos: [...] });
// Equivalent to passing rawData at the .build() level:
//   .build({ pubkey: '03abc...', utxos: [...], rawData: pdfBuffer })

Under the Hood

When .build() is called, the following steps happen in sequence:

  1. _validate() checks BATCH protocol constraints (empty, nested BATCH, DEPLOY, FILE/MINT/ISSUE counts).
  2. For each queued action, sdk.actions.createAction({ action, params }) is called. This runs the full pipeline: field validation → format selection → serialization. The result’s actionString is collected. If the action is FILE and its params include rawData, rawData is extracted and saved; it is not passed through to the action string.
  3. All actionString values are joined with ;: SEND|0|BTC.TOKEN|100|addr1...;SEND|0|BTC.TOKEN|50|addr2...
  4. If a FILE sub-action provided rawData, it is merged into encoder opts.
  5. The joined string is passed as the command param to sdk.createAction({ action: 'BATCH', params: { command }, encoder: encoderOpts }), which serializes the outer BATCH action: BATCH|0|SEND|0|...;SEND|0|...

The BATCH action itself uses format version 0 (VERSION|COMMAND), there is only one BATCH format.

flowchart TD
    S1["1. validate(), check BATCH protocol constraints"]
    S2["2. createAction() per queued action, validate, format select, serialize"]
    S3["3. join all actionString values with semicolon"]
    S4["4. hoist FILE rawData into encoder opts, if provided"]
    S5["5. wrap the joined string as the outer BATCH action"]

    S1 --> S2 --> S3 --> S4 --> S5

Copyright © 2025–2026 Dankest, LLC

Based on XChain Platform by Dankest, LLC – https://dankest.llc

Licensed under the GNU Affero General Public License v3.0 (AGPL-3.0-or-later) with a commercial license available for proprietary use.

You may use, modify, and distribute this material under the terms of the License. See LICENSE and NOTICE for full terms. See the licensing overview.

Edit this page on GitHub ↗