tusdt-sdk
v0.3.5
Published
TUSDT (PSP22) payment SDK for Substrate — React provider, hooks and dialog on top of a dedot engine
Maintainers
Readme
tusdt-sdk
v0.3.4 — fixes raw injected signer address loss when using LunoKit / dedot / polkadot-extension wallets (payment no longer throws
normalizeSigner: received a raw injected signer with no address).
React payment SDK for the TUSDT PSP22 ink! smart contract on Bittensor / Substrate.
Drop a <PaymentButton> into your app, let a user sign a TUSDT transfer to your platform wallet, and receive a finalized receipt ({ blockHash, extrinsicHash, blockNumber }) you can POST to your backend.
- React 18 provider + hooks built on @tanstack/react-query
- Accessible payment dialog (Radix UI) with light/dark themes and CSS-variable theming
- BYO signer — pass your wallet
account+signeras props (works with any dedot-compatible injector, e.g. LunoKit, Talisman, SubWallet) - Tree-shakeable subpath exports — frontends never pull engine code they don't use
- Ships chain presets for Bittensor mainnet + testnet
- Full TypeScript types, SSR-safe (Next.js app-router compatible)
Install
npm install tusdt-sdk dedot @tanstack/react-query react react-dom@tanstack/react-query, react, and react-dom are peer dependencies — the SDK reuses your app's existing instances. If you don't already have a QueryClient, the provider creates one for you.
Quick start (3 steps)
1. Define a config
// app/tusdt-config.ts
import { createConfig } from 'tusdt-sdk/config';
import { bittensorTestnet } from 'tusdt-sdk/chains';
export const tusdtConfig = createConfig({
appName: 'My Storefront',
chains: [bittensorTestnet],
defaultRecipient: '5GGq...platformWallet',
});2. Mount the provider
// app/providers.tsx
'use client';
import { TusdtKitProvider } from 'tusdt-sdk/ui';
import 'tusdt-sdk/ui/styles.css';
import { tusdtConfig } from './tusdt-config';
export function Providers({ children, account, signer }) {
return (
<TusdtKitProvider
config={tusdtConfig}
account={account} // { address, meta? } from your wallet connector
signer={signer} // dedot-compatible injected signer
theme={{ mode: 'light' }}
>
{children}
</TusdtKitProvider>
);
}3. Render a payment button
import { PaymentButton } from 'tusdt-sdk/ui';
<PaymentButton
recipient="5GGq...platformWallet"
amount={1_000_000_000n} // 1.000000000 TUSDT (9 decimals)
onSuccess={async (receipt) => {
await fetch('/api/payments/confirm', {
method: 'POST',
body: JSON.stringify(receipt), // { blockHash, extrinsicHash, blockNumber }
});
}}
/>That's it. The dialog handles balance display, address truncation, fee preview, signing, broadcast, and the in-block → finalized status machine.
Package layout
The SDK ships as a single package with subpath exports so frontends only bundle what they import.
| Import | Contents |
|-------------------------------|---------------------------------------------------------------------|
| tusdt-sdk | Engine: TusdtContract, executeTransfer, errors, formatters |
| tusdt-sdk/config | createConfig, config types |
| tusdt-sdk/chains | defineChain, bittensorMainnet, bittensorTestnet |
| tusdt-sdk/react | TusdtProvider, hooks (useBalance, useTransferTusdt, …) |
| tusdt-sdk/ui | TusdtKitProvider, PaymentDialog, PaymentButton |
| tusdt-sdk/ui/styles.css | Dialog stylesheet |
Wallet connection (BYO)
The SDK accepts any of the common signer shapes via an internal normalizeSigner helper — no manual wrapping needed:
@luno-kit/react—useSigner()returns a{ data?, isLoading }wrapper which the SDK auto-unwraps. Pass it directly:import { useAccount, useSigner } from '@luno-kit/react'; import { TusdtKitProvider } from 'tusdt-sdk/ui'; function AppShell({ children }) { const { account } = useAccount(); const signer = useSigner(); // wrapper { data?, isLoading } — auto-unwrapped return ( <TusdtKitProvider config={tusdtConfig} account={account} signer={signer}> {children} </TusdtKitProvider> ); }Equivalent:
signer={useSigner().data}. WhileuseSigner().dataisundefined, the dialog renders read-only.dedotdirect —connector.getSigner()returns a rawInjectedSigner(no address). Wrap it with{ address, signer }:import { TusdtKitProvider } from 'tusdt-sdk/ui'; function AppShell({ children }) { const [account, setAccount] = useState(null); const [rawSigner, setRawSigner] = useState(null); // after connecting via dedot connector: // setAccount(accounts[0]); setRawSigner(await connector.getSigner()); const signer = account && rawSigner ? { address: account.address, signer: rawSigner } : undefined; return ( <TusdtKitProvider config={tusdtConfig} account={account} signer={signer}> {children} </TusdtKitProvider> ); }@polkadot/extension-dapp—web3FromAddress(addr)gives{ signer, ... }. Wrap with address, same as dedot:const injected = await web3FromAddress(account.address); <TusdtKitProvider config={tusdtConfig} account={{ address: account.address }} signer={{ address: account.address, signer: injected.signer }} />@polkadot/keyring(Node / tests) —IKeyringPairhas its ownaddress:import { Keyring } from '@polkadot/keyring'; const pair = new Keyring({ type: 'sr25519' }).addFromUri('//Alice'); <TusdtKitProvider config={tusdtConfig} account={{ address: pair.address }} signer={pair} />Already-wrapped
{ address, signer: { signPayload } }shapes also work as-is.
If you need the normalizer in your own code (e.g. for the engine API outside React):
import { normalizeSigner } from 'tusdt-sdk';
const n = normalizeSigner(signer, account.address); // -> { address, kind, raw, signer?/pair? }If account/signer are undefined, the dialog renders in a read-only state — users still see the recipient/amount, but the Pay button is disabled until a wallet is connected.
Default recipient
recipient is optional on <PaymentButton> and <PaymentDialog> when you set defaultRecipient in createConfig({ defaultRecipient }). The dialog falls back to it automatically:
// config sets defaultRecipient
<PaymentButton amount={1_000_000_000n} onSuccess={...} />Imperative payment modal
Use usePaymentModal() to open the payment dialog from anywhere inside <TusdtKitProvider> without rendering <PaymentDialog> yourself:
import { usePaymentModal } from 'tusdt-sdk/ui';
function CheckoutButton() {
const { openPaymentModal } = usePaymentModal();
return (
<button
onClick={() =>
openPaymentModal({
recipient: '5GGq...platformWallet',
amount: 2_500_000_000n,
title: 'Order #1234',
onSuccess: (receipt) => postToBackend(receipt),
})
}
>
Checkout
</button>
);
}<TusdtKitProvider> mounts a single <PaymentDialog> driven by this state.
API reference
createConfig(params)
import { createConfig } from 'tusdt-sdk/config';
createConfig({
appName?: string;
chains: readonly TusdtChain[]; // required, non-empty
defaultRecipient?: string; // platform wallet for payments
autoConnect?: boolean;
queryClient?: QueryClient; // optional, host-owned
});Returns a frozen, validated TusdtConfig. Throws if chains is empty.
defineChain(chain)
import { defineChain } from 'tusdt-sdk/chains';
const myChain = defineChain({
genesisHash: '0x...',
name: 'My Substrate Chain',
nativeCurrency: { name: 'Unit', symbol: 'UNIT', decimals: 12 },
rpcUrls: { webSocket: ['wss://rpc.example.com'] },
ss58Format: 42,
testnet: false,
token: {
name: 'TUSDT',
symbol: 'TUSDT',
contractAddress: '5GGq...',
decimals: 9,
},
});Built-in presets: bittensorMainnet, bittensorTestnet.
Hooks
All hooks must be called inside a <TusdtProvider> (or <TusdtKitProvider>).
import {
useTusdtConfig, useChain, useApi, useAccount,
useTusdtContract, useTusdtMetadata,
useBalance, useTransferTusdt, useDryRunTransfer,
usePaymentModal,
} from 'tusdt-sdk/react';| Hook | Returns |
|-------------------------------|---------|
| useTusdtConfig() | The frozen config object |
| useChain() | { chain, chainId, chains, switchChain(genesisHash) } |
| useApi() | { client, isApiReady, error, reconnect() } (dedot client) |
| useAccount() | { account?, signer?, isConnected } |
| useTusdtContract() | Memoized TusdtContract for the active chain, or null |
| useTusdtMetadata() | { symbol, decimals, contractAddress } from the active chain |
| useBalance({ address? }) | UseQueryResult<{ raw, formatted, symbol, decimals }> — 12s refetch |
| useDryRunTransfer({ recipient, amount }) | UseQueryResult<{ refTime, proofSize }> |
| useTransferTusdt() | See below |
| usePaymentModal() | { isOpen, open(props?), close() } — imperative dialog control |
useTransferTusdt()
const {
transfer, // (args) => void
transferAsync, // (args) => Promise<receipt>
isPending,
status, // react-query status
detailedStatus, // 'idle' | 'signing' | 'broadcasting' | 'inBlock' | 'finalized' | 'success' | 'failed'
data, // { blockHash, extrinsicHash, blockNumber } on success
error,
reset,
} = useTransferTusdt({ onSuccess, onError });
transfer({ recipient: '5GGq...', amount: 1_000_000_000n });On success the hook invalidates ['tusdt','balance', chainId, contractAddress] so any mounted useBalance refetches automatically.
<PaymentDialog />
<PaymentDialog
open={open}
onOpenChange={setOpen}
recipient="5GGq..."
amount={1_000_000_000n} // omit to let the user enter an amount
amountFixed={true} // hide AmountInput
title="Pay with TUSDT"
description="Complete your order"
onSuccess={(receipt) => {}}
onError={(err) => {}}
portalContainer={document.body}
/><PaymentButton />
Convenience wrapper — renders a button that opens PaymentDialog. Same payment props as the dialog, plus standard <button> props.
createWebhookPayload(receipt, { recipient, amount })
Helper to build the exact JSON body to POST to your platform backend:
import { createWebhookPayload } from 'tusdt-sdk/react';
const body = createWebhookPayload(receipt, { recipient, amount });
// { blockHash, extrinsicHash, blockNumber, recipient, amount: "1000000" }Theming
Pass a theme prop to the provider:
<TusdtKitProvider
config={tusdtConfig}
theme={{
mode: 'dark',
colors: {
accent: '#8b5cf6',
accentForeground: '#ffffff',
surface: '#0b0b0f',
},
}}
>Theme values are applied as CSS variables (--tusdt-accent, --tusdt-surface, …) on the provider's wrapper div. You can also override them directly in your own CSS:
.my-app {
--tusdt-accent: #f43f5e;
--tusdt-radius: 12px;
}Make sure to import the stylesheet once at your app root:
// Next.js (app router)
// app/layout.tsx
import 'tusdt-sdk/ui/styles.css';
// Vite / CRA
// src/main.tsx
import 'tusdt-sdk/ui/styles.css';Backend webhook flow
┌────────┐ PaymentButton ┌───────────┐ sign+broadcast ┌──────────┐
│ User │ ─────────────▶ │ Wallet │ ─────────────────▶ │ Bittensor│
└────────┘ └───────────┘ └────┬─────┘
▲ │ finalized
│ onSuccess(receipt) ▼
│ ◀───────────────────────────────── PaymentDialog ◀─── TxResult
│ │
│ ▼
│ POST /api/payments/confirm
│ { blockHash, extrinsicHash, blockNumber }
│ │
│ ▼
│ Your backend
│ (verify on-chain, credit user)Your backend should independently verify the transaction by querying the chain at blockHash — never trust the client receipt alone.
Engine API (lower-level)
If you only need the contract engine (e.g. for a Node.js backend or non-React frontend), import directly from the root:
import { TusdtContract, executeTransfer, formatBalance, parseBalance } from 'tusdt-sdk';
import { DedotClient, WsProvider } from 'dedot';
const client = await DedotClient.legacy(new WsProvider('wss://test.finney.opentensor.ai:443'));
const tusdt = new TusdtContract(client, CONTRACT_ADDRESS);
const balance = await tusdt.balanceOf(address);
console.log(formatBalance(balance));
await tusdt.transfer(signer, recipient, parseBalance('1.5'));Available functions: queryController, queryTotalSupply, queryBalanceOf, queryAllowance, executeTransfer, executeApprove, executeTransferFrom, executeMint, executeBurn, executeIncreaseAllowance, executeDecreaseAllowance, safeApprove.
Validation helpers: validateAddress, validateBalance, validateOverflowSafe.
Formatting: formatBalance, parseBalance, TUSDT_DECIMALS.
Errors
All SDK errors extend TusdtSdkError with a typed code field. Branch on code, never on message:
import { TusdtSdkError, ErrorCode } from 'tusdt-sdk';
try {
await transferAsync({ recipient, amount });
} catch (err) {
if (err instanceof TusdtSdkError) {
switch (err.code) {
case ErrorCode.INSUFFICIENT_BALANCE: /* … */ break;
case ErrorCode.INVALID_ADDRESS: /* … */ break;
case ErrorCode.DRY_RUN_FAILED: /* … */ break;
case ErrorCode.DISPATCH_FAILED: /* … */ break;
case ErrorCode.CONNECTION_FAILED: /* … */ break;
}
}
}| Code | Meaning |
|------|---------|
| INVALID_ADDRESS / INVALID_AMOUNT / BALANCE_OVERFLOW | Validation failed before sending |
| INSUFFICIENT_BALANCE / INSUFFICIENT_ALLOWANCE | Contract rejection |
| NOT_CONTROLLER | Caller not authorized for mint/burn |
| DRY_RUN_FAILED | Pre-flight simulation rejected the tx |
| DISPATCH_FAILED | Tx included on-chain but execution failed |
| CONNECTION_FAILED | WebSocket connection lost |
SSR / Next.js
The provider is fully SSR-safe — WebSocket connections are deferred to useEffect. Mount <TusdtKitProvider> inside a 'use client' boundary:
// app/providers.tsx
'use client';
import { TusdtKitProvider } from 'tusdt-sdk/ui';
// …useBalance, useTransferTusdt, etc. return isApiReady: false until the client is connected — the dialog handles this state.
License
MIT
Development
npm install
npm run build # tsup + tsc
npm run check # biome lint + format check
npm run format # biome format --write
npm run test:unit # vitest