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
ActionResultshape assdk.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:
_validate()checks BATCH protocol constraints (empty, nested BATCH, DEPLOY, FILE/MINT/ISSUE counts).- For each queued action,
sdk.actions.createAction({ action, params })is called. This runs the full pipeline: field validation → format selection → serialization. The result’sactionStringis collected. If the action is FILE and its params includerawData,rawDatais extracted and saved; it is not passed through to the action string. - All
actionStringvalues are joined with;:SEND|0|BTC.TOKEN|100|addr1...;SEND|0|BTC.TOKEN|50|addr2... - If a FILE sub-action provided
rawData, it is merged into encoder opts. - The joined string is passed as the
commandparam tosdk.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.