tusdt-sdk
v0.1.5
Published
TypeScript SDK for TUSDT ERC20 ink! smart contract on Substrate using Dedot
Maintainers
Readme
tusdt-sdk
TypeScript SDK for the TUSDT ERC20 ink! smart contract on Substrate / Bittensor.
- Zero-reorg payment detection via finalized blocks only
- Historical block sync from a configurable
startBlockfor crash-safe restarts - Gap recovery between live subscription events
- Automatic reconnection with exponential backoff
- Structured, machine-readable errors with typed codes
- Tree-shakeable standalone functions — include only what you use
- Full TypeScript types with JSDoc
Installation
npm install tusdt-sdkPeer dependencies (required for backend / signer usage):
npm install dedot @polkadot/keyring @polkadot/util-cryptoQuick start
import { connect, TusdtContract, TusdtPaymentListener, formatBalance } from 'tusdt-sdk/server';
const client = await connect('wss://test.finney.opentensor.ai:443');
const tusdt = new TusdtContract(client, CONTRACT_ADDRESS);
console.log(formatBalance(await tusdt.totalSupply()));
const listener = new TusdtPaymentListener(client, CONTRACT_ADDRESS, {
receiverWallet: MY_WALLET,
onPayment: async (event) => {
console.log(`Received ${formatBalance(event.value)} TUSDT in block #${event.blockNumber}`);
},
});
await listener.start();Entrypoints
| Import path | Use case |
|----------------------|------------------------------------------------------|
| tusdt-sdk | Browser / universal — no Node.js-only deps |
| tusdt-sdk/server | Node.js backend — includes connect(), TusdtPaymentListener |
API reference
connect(wsUrl) — server only
Creates a connected DedotClient (legacy JSON-RPC mode, compatible with all Substrate / Bittensor nodes).
import { connect } from 'tusdt-sdk/server';
const client = await connect('wss://test.finney.opentensor.ai:443');createSigner(suri, type?) — server only
Convenience helper for backend/test environments. Requires @polkadot/keyring.
const signer = await createSigner('//Alice');TusdtContract
High-level class wrapping all read (query) and write (tx) operations.
const tusdt = new TusdtContract(client, contractAddress);
// Queries (read-only, no gas)
const supply = await tusdt.totalSupply();
const balance = await tusdt.balanceOf(address);
const allowed = await tusdt.allowance(owner, spender);
// Transactions (sign + submit)
await tusdt.transfer(signer, recipient, 1_000_000n);
await tusdt.approve(signer, spender, 500_000n);
await tusdt.transferFrom(signer, from, to, 500_000n);
await tusdt.mint(signer, recipient, 2_000_000n); // controller only
await tusdt.burn(signer, holder, 1_000_000n); // controller onlyTusdtContractOptions
interface TusdtContractOptions {
/** SS58 address used as caller for dry-runs. Defaults to zero address. */
defaultCaller?: AccountId;
}TusdtPaymentListener — server only
Production-grade listener for incoming TUSDT transfers on finalized blocks. Mirrors the architecture of tusdt-server's bt-listener service.
import { connect, TusdtPaymentListener, ErrorCode } from 'tusdt-sdk/server';
const client = await connect(WS_URL);
// Load persisted state
const lastSynced = await db.getLastSyncedBlock(); // e.g. 4_200_000
const listener = new TusdtPaymentListener(client, CONTRACT_ADDRESS, {
receiverWallet: RECEIVER_ADDRESS,
// Resume from last processed block (pass undefined for live-only)
startBlock: lastSynced !== undefined ? lastSynced + 1 : undefined,
// Called for every matching transfer on a finalized block
onPayment: async (event) => {
await db.recordPayment(event);
},
// Persist progress after every block (enables crash-safe resume)
onBlockSynced: async (blockNumber, blockHash) => {
await db.saveLastSyncedBlock(blockNumber, blockHash);
},
// Informational lifecycle events — NOT errors
onProgress: ({ type, message, blockRange }) => {
const range = blockRange ? ` [${blockRange.from}→${blockRange.to}]` : '';
console.log(`[${type}]${range} ${message}`);
},
// Actionable errors only
onError: (err) => {
switch (err.code) {
case ErrorCode.BLOCK_PROCESSING_FAILED:
// Non-fatal — listener continues to next block
console.warn(`[BLOCK_ERROR] ${err.message}`);
if (err.cause) console.warn(' caused by:', err.cause.message);
break;
case ErrorCode.CONNECTION_FAILED:
console.error(`[CONNECTION] ${err.message}`);
break;
case ErrorCode.RECONNECT_EXHAUSTED:
console.error('[RECONNECT_EXHAUSTED] giving up');
void listener.stop().then(() => client.disconnect()).then(() => process.exit(1));
break;
default:
console.error(`[${err.code}] ${err.message}`);
if (err.cause) console.error(' caused by:', err.cause.message);
}
},
reconnect: {
maxRetries: Infinity,
baseDelayMs: 1_000,
maxDelayMs: 30_000,
},
});
await listener.start();
console.log('Listener running. Press Ctrl+C to stop.');
process.on('SIGINT', async () => {
await listener.stop();
await client.disconnect();
});PaymentListenerConfig
| Field | Type | Required | Description |
|------------------|----------------------------------------------|----------|-------------|
| receiverWallet | string | ✓ | SS58 address to watch for incoming transfers |
| onPayment | (event: FinalizedTransferEvent) => void \| Promise<void> | ✓ | Called for each matching finalized transfer |
| onBlockSynced | (blockNumber: number, blockHash: string) => void \| Promise<void> | — | Called after every processed block — use to persist resume position |
| onProgress | (progress: ListenerProgress) => void | — | Informational lifecycle events (history sync, gap fill, reconnect attempts) |
| onError | (error: TusdtSdkError) => void | — | Actionable errors only — block failures, connection drops, reconnect exhausted |
| startBlock | number | — | First block to sync. If set, historical catch-up runs before live subscription |
| reconnect | ReconnectConfig \| false | — | Reconnect strategy. Default: { maxRetries: Infinity, baseDelayMs: 1000, maxDelayMs: 30000 } |
onProgress vs onError
These are two distinct channels:
onProgress— informational only, never indicates a problem. Events:SUBSCRIPTION_STARTED,HISTORY_SYNC_START,HISTORY_SYNC_DONE,GAP_FILL_START,GAP_FILL_DONE,BLOCK_PROCESSED,RECONNECTING.onError— always aTusdtSdkErrorrequiring attention. Useerr.codeto branch without string-matching.
ListenerProgress
interface ListenerProgress {
type:
| 'SUBSCRIPTION_STARTED' // live subscription established
| 'HISTORY_SYNC_START' // about to process N historical blocks
| 'HISTORY_SYNC_DONE' // historical sync complete
| 'GAP_FILL_START' // missed blocks detected, filling gap
| 'GAP_FILL_DONE' // gap fill complete
| 'BLOCK_PROCESSED' // a single block was processed
| 'RECONNECTING'; // reconnect attempt in progress
message: string; // human-readable description
blockNumber?: number; // relevant block (if applicable)
blockRange?: { from: number; to: number }; // relevant block range (if applicable)
}ReconnectConfig
interface ReconnectConfig {
maxRetries?: number; // Default: Infinity
baseDelayMs?: number; // Default: 1000
maxDelayMs?: number; // Default: 30000
}Listener properties
listener.isRunning // boolean — true while subscribed
listener.lastBlockNumber // number — last successfully processed finalized blockFinalizedTransferEvent
interface FinalizedTransferEvent {
from: string | null; // sender SS58 address (null for mint)
to: string | null; // recipient SS58 address (null for burn)
value: bigint; // token amount (u64)
blockHash: string; // finalized block hash (0x-prefixed hex)
blockNumber: number; // finalized block number
txIndex?: number; // position among Transfer events in this block
}Error handling
Every error thrown by tusdt-sdk is a TusdtSdkError. All SDK errors carry:
code— machine-readableTusdtErrorCodefor programmatic branching (never string-matchmessage)cause— the underlyingErrorthat triggered this one, if anytoJSON()— serialisable representation safe for loggers / JSON.stringifytoString()—ErrorName [CODE]: message\n caused by: ...
import { TusdtSdkError, ErrorCode } from 'tusdt-sdk';
import type { InvalidAddressError } from 'tusdt-sdk';
try {
await tusdt.transfer(signer, recipient, amount);
} catch (err) {
if (err instanceof TusdtSdkError) {
switch (err.code) {
case ErrorCode.INSUFFICIENT_BALANCE:
console.error('Not enough tokens');
break;
case ErrorCode.INVALID_ADDRESS:
console.error(`Bad address: ${(err as InvalidAddressError).address}`);
break;
case ErrorCode.DRY_RUN_FAILED:
console.error('Dry-run failed — tx would fail on-chain');
break;
default:
console.error(`[${err.code}] ${err.message}`);
if (err.cause) console.error(' caused by:', err.cause.message);
}
}
}Error codes
| Code | Class | Thrown when |
|------|-------|-------------|
| INVALID_ADDRESS | InvalidAddressError | Address is not a valid SS58 or 32-byte hex |
| INVALID_AMOUNT | InvalidAmountError | Amount is not a bigint, is negative, or exceeds u64 max |
| BALANCE_OVERFLOW | OverflowError | Transfer would overflow recipient's u64 balance |
| INVALID_BALANCE_STRING | InvalidBalanceStringError | Human-readable balance string could not be parsed |
| INSUFFICIENT_BALANCE | InsufficientBalanceError | Contract: sender lacks token balance |
| INSUFFICIENT_ALLOWANCE | InsufficientAllowanceError | Contract: spender lacks approved allowance |
| NOT_CONTROLLER | NotControllerError | Contract: caller is not the controller (mint/burn) |
| CONTRACT_REVERTED | ContractError | Contract rejected the call with an unrecognised variant |
| DRY_RUN_FAILED | DryRunError | Pre-flight simulation failed (no gas consumed) |
| DISPATCH_FAILED | DispatchError | On-chain extrinsic failed during execution (gas consumed) |
| CONNECTION_FAILED | ConnectionError | WebSocket connection could not be established or was lost |
| RECONNECT_EXHAUSTED | ReconnectExhaustedError | Listener gave up after exhausting maxRetries |
| BLOCK_PROCESSING_FAILED | BlockProcessingError | A block could not be queried or decoded (non-fatal in listener) |
| TIMEOUT | TimeoutError | Operation did not complete within the expected time |
Error class hierarchy
TusdtSdkError (code, message, cause)
├── ValidationError
│ ├── InvalidAddressError (.address: string)
│ ├── InvalidAmountError (.value: unknown)
│ ├── OverflowError (.currentBalance: bigint, .addAmount: bigint)
│ └── InvalidBalanceStringError (.input: string)
├── ContractError (.contractErrorType: string)
│ ├── InsufficientBalanceError
│ ├── InsufficientAllowanceError
│ └── NotControllerError
├── DryRunError (.dispatchError: unknown)
├── DispatchError (.dispatchError: unknown)
├── ConnectionError (.wsUrl?: string)
├── ReconnectExhaustedError (.attempts: number)
├── BlockProcessingError (.blockNumber: number, .blockHash?: string)
└── TimeoutErrorContext fields
Each error class exposes structured context — no need to parse message strings:
import {
InvalidAddressError,
OverflowError,
BlockProcessingError,
ReconnectExhaustedError,
ConnectionError,
} from 'tusdt-sdk';
if (err instanceof InvalidAddressError) {
console.error(err.address); // the bad address value
}
if (err instanceof OverflowError) {
console.error(err.currentBalance); // recipient's current balance
console.error(err.addAmount); // amount that would overflow
}
if (err instanceof BlockProcessingError) {
console.error(err.blockNumber); // which block failed
console.error(err.blockHash); // block hash if known
}
if (err instanceof ReconnectExhaustedError) {
console.error(err.attempts); // how many retries were made
}
if (err instanceof ConnectionError) {
console.error(err.wsUrl); // which WebSocket URL failed
}Standalone tree-shakeable functions
For advanced use or frontend bundles, every operation is also available as a standalone function. Import only what you need:
import { queryBalanceOf, queryTotalSupply } from 'tusdt-sdk';
import { executeTransfer } from 'tusdt-sdk';
import { watchFinalizedTransfers } from 'tusdt-sdk';
import { subscribeTransfer } from 'tusdt-sdk';
import { safeApprove } from 'tusdt-sdk';
import { validateAddress, formatBalance } from 'tusdt-sdk';Query functions
queryController(client, contract): Promise<AccountId>
queryTotalSupply(client, contract): Promise<Balance>
queryBalanceOf(client, contract, address): Promise<Balance>
queryAllowance(client, contract, owner, spender): Promise<Balance>Transaction functions
executeTransfer(client, contract, signer, to, amount, opts?): Promise<TxResult>
executeApprove(client, contract, signer, spender, amount, opts?): Promise<TxResult>
executeTransferFrom(client, contract, signer, from, to, amount, opts?): Promise<TxResult>
executeMint(client, contract, signer, to, amount, opts?): Promise<TxResult>
executeBurn(client, contract, signer, from, amount, opts?): Promise<TxResult>
executeIncreaseAllowance(client, contract, signer, spender, delta, opts?): Promise<TxResult>
executeDecreaseAllowance(client, contract, signer, spender, delta, opts?): Promise<TxResult>Finalized event watchers
watchFinalizedTransfers(client, contract, callback, opts?): Promise<Unsubscribe>
watchFinalizedTransfersTo(client, contract, account, callback, opts?): Promise<Unsubscribe>Best-chain event subscriptions
subscribeTransfer(client, contract, callback, opts?): Promise<Unsubscribe>
subscribeApproval(client, contract, callback, opts?): Promise<Unsubscribe>
subscribeTransferFrom(client, contract, account, callback, opts?): Promise<Unsubscribe>
subscribeTransferTo(client, contract, account, callback, opts?): Promise<Unsubscribe>Helper
// Safe approve: sets allowance to 0 then to target amount (avoids double-spend race)
safeApprove(client, contract, signer, spender, amount, opts?): Promise<[TxResult, TxResult]>Token utilities
import { formatBalance, parseBalance, TUSDT_DECIMALS } from 'tusdt-sdk';
TUSDT_DECIMALS // 6
formatBalance(1_000_000n) // "1.000000"
formatBalance(1_500_000n) // "1.500000"
parseBalance("1.5") // 1_500_000n
parseBalance("1500000", false) // 1_500_000n (raw integer, no decimal shift)formatBalance throws InvalidAmountError if the input is not a non-negative bigint.parseBalance throws InvalidBalanceStringError for malformed strings and InvalidAmountError for negative results.
Validation utilities
import { validateBalance, validateAddress, validateOverflowSafe } from 'tusdt-sdk';
// Throws InvalidAmountError if value is not a valid non-negative bigint ≤ u64 max
validateBalance(amount);
// Throws InvalidAddressError if address is not valid SS58 / hex
validateAddress(address);
// Throws OverflowError if currentBalance + addAmount > U64_MAX
validateOverflowSafe(currentBalance, addAmount);SubstrateClient interface
The SDK accepts any object matching this interface:
interface SubstrateClient {
rpc: {
chain_getFinalizedHead(): Promise<unknown>;
chain_getHeader(hash: string): Promise<{ number: unknown }>;
chain_getBlockHash(n: number): Promise<unknown>;
chain_subscribeFinalizedHeads(cb: (header: any) => void): Promise<unknown>;
chain_subscribeNewHeads(cb: (header: any) => void): Promise<unknown>;
};
query: any;
/** Returns a client snapshot bound to blockHash — used for historical queries. */
at(blockHash: string): Promise<{ query: any }>;
disconnect(): Promise<void>;
}The connect() helper returns a DedotClient.legacy() instance that satisfies this interface.
Publishing
cd tusdt-sdk
npm run build
npm publish --access publicBuild output (CommonJS + ESM + types) is emitted to dist/ via tsup.
Testing with the consumer script
cd test-consumer
npm install
node index.mjsEnvironment variables:
| Variable | Default |
|-----------------|---------|
| WS_URL | wss://test.finney.opentensor.ai:443 |
| CONTRACT_ADDR | Testnet TUSDT contract address |
| RECEIVER_ADDR | Wallet address to watch |
| START_BLOCK | Block number to start historical sync from |
