Wallet Fundamentals: Build, Sign, and Send Your First Transaction in WDK
15 minutes read
In the first post, Introduction to WDK: Tether's Wallet Development Kit, we introduced WDK's vision: a unified interface for multi-chain wallet development. By the end of this post, you'll have a working wallet that can send transactions across multiple blockchains, without rewriting code for each chain. This post is hands-on.
We'll move from concept to code in four steps: creating a wallet from a seed phrase, managing accounts across chains, signing and broadcasting transactions, and enabling gasless flows with account abstraction. Each step is a complete, copy-paste example.
Step 1: Set Up and Create Your First Wallet
WDK runs on both Node.js and Bare (a lightweight JavaScript runtime optimized for resource-constrained environments). Choose the runtime that fits your use case.
Option A: Bare (Lightweight Runtime for Embedded)
Bare is a minimal JavaScript runtime that runs on any device (IoT, embedded systems, mobile, or desktop). Use this if you're building wallets for constrained environments or peer-to-peer applications.
Install Bare globally:
npm install -g bareInitialize your Bare project:
mkdir my-wdk-wallet
cd my-wdk-wallet
npm init -y
npm pkg set type=module
# Install Bare Kit for React Native or embedded integrations
npm install --save-dev bare
Install WDK:
npm install @tetherto/wdkCreate your wallet script:
touch wallet.jsAdd the same code as the Node.js version:
import WDK from '@tetherto/wdk'
// Generate a 24-word BIP-39 seed phrase
const seedPhrase = WDK.getRandomSeedPhrase(24)
console.log('Your seed phrase:', seedPhrase)
// Initialize WDK with the seed
const wdk = new WDK(seedPhrase)
console.log('Wallet created successfully')What's happening:
- We import WDK and generate a seed phrase using the BIP-39 standard. The 24 specifies the length of the phrase (24 words), which provides the highest level of entropy. If you omit the number, the library may default to a shorter phrase (commonly 12 words), which is still secure but has lower entropy.
- We then initialize a wallet using that seed. All operations happen locally; nothing is sent to external servers.WDK is fully self-custodial; only you control the seed phrase and derived accounts.
The wallet is fully self-custodial: whoever has the seed phrase has full control over the funds. For the aim of the tutorial, we logged the seed phrase, never share it, log it, paste it in chat, or store it in code!
Run it with Bare:
bare wallet.jsYou should see your generated seed phrase and a success message.
Option B: Node.js
Node.js is the most familiar environment and works everywhere. Use this if you're building a server, desktop app, or just learning WDK.
Initialize your Node.js project:
# Create a new directory for your wallet project
mkdir my-wdk-wallet
cd my-wdk-wallet
# Initialize a new Node.js project
npm init -y
# Enable ES modules (recommended)
npm pkg set type=moduleInstall WDK:
npm install @tetherto/wdkCreate your first wallet script:
touch wallet.jsOpen wallet.js and add:
import WDK from '@tetherto/wdk'
// Generate a secure random seed phrase
const seedPhrase = WDK.getRandomSeedPhrase(24)
console.log('Your seed phrase:', seedPhrase)
// Or use 12-word seed phrase (default)
// const seedPhrase = WDK.getRandomSeedPhrase()
// Initialize WDK with the seed
const wdk = new WDK(seedPhrase)
console.log('Wallet created successfully')What's happening:
- We import WDK and generate a seed phrase using the BIP-39 standard. The 24 specifies the length of the phrase (24 words), which provides the highest level of entropy. If you omit the number, the library may default to a shorter phrase (commonly 12 words), which is still secure but has lower entropy.
- We then initialize a wallet using that seed. All operations happen locally; nothing is sent to external servers.WDK is fully self-custodial; only you control the seed phrase and derived accounts.
- The wallet is fully self-custodial: whoever has the seed phrase has full control over the funds. For the aim of the tutorial, we logged the seed phrase, never share it, log it, paste it in chat, or store it in code!
Run it with Node.js:
node wallet.js
The code is identical; that's the power of WDK's portability. Whether you use Node.js or Bare, your wallet logic runs the same.
Seed Phrase Security
Critical: Back up your seed phrase securely. Write it down on paper or store it in a vault. If you lose the seed, you lose access to every account it generates. If someone else gets it, they control everything. For development and testing, losing a testnet seed phrase is fine. For mainnet, treat the seed phrase like a nuclear launch code.
You can also restore an existing seed phrase:
const existingSeedPhrase = 'your twelve or twenty four word phrase here' // only for development and testing, no seed phrase with real funds!
const wdk = new WDK(existingSeedPhrase)Behind the scenes, WDK has instantiated a Core Module, the orchestrator we described in the first post. It's ready to register wallet modules and manage accounts, but it doesn't yet know about any blockchains. That's next.
Step 2: Register Modules and Manage Accounts
Note: The following steps (Step 2, 3, and 4) are identical for both Node.js and Bare. Whether you chose Option A or Option B in Step 1, the npm install commands and all JavaScript code that follows work exactly the same.
Now register the blockchains you want to support. Each requires its wallet module and a configuration object (at minimum, an RPC provider).
First, install the wallet modules for Bitcoin, Ethereum, and TON, the ones we are gonna use in this tutorial:
npm install @tetherto/wdk-wallet-btc @tetherto/wdk-wallet-evm @tetherto/wdk-wallet-tonThen register them with your WDK instance:
import WDK from '@tetherto/wdk'
import WalletManagerBtc from '@tetherto/wdk-wallet-btc'
import WalletManagerEvm from '@tetherto/wdk-wallet-evm'
import WalletManagerTon from '@tetherto/wdk-wallet-ton'
const seedPhrase = 'your seed phrase here' // only for development and testing, no seed phrase with real funds!
const wdk = new WDK(seedPhrase)
.registerWallet('ethereum', WalletManagerEvm, {
provider: 'https://eth.drpc.org'
// Replace with your testnet RPC endpoint
})
.registerWallet('bitcoin', WalletManagerBtc, {
provider: 'https://blockstream.info/api'
})
.registerWallet('ton', WalletManagerTon, {
tonClient: {
url: 'https://toncenter.com/api/v3',
}
})
console.log('All chains registered')
What's happening here:
This is the builder pattern in action.
- Each call to .registerWallet() returns a new WDK instance with that blockchain module added. By chaining the calls, you build up a single wdk instance that knows how to manage wallets across all three blockchains.
- At the end, wdk is fully configured and ready to derive accounts, check balances, and send transactions; same API for all chains.
Alternative: Step-by-step approach (for clarity)
If you prefer to see each step explicitly, you can break down the chaining:
const wdk = new WDK(seedPhrase)
// Step 1: Register Ethereum
const wdk = wdk.registerWallet('ethereum', WalletManagerEvm, {
provider: 'https://eth-sepolia.g.alchemy.com/v2/YOUR_API_KEY'
})
// Step 2: Register Bitcoin onto the Ethereum instance
const wdk = wdk.registerWallet('bitcoin', WalletManagerBtc, {
provider: 'https://electrum.blockstream.info'
})
// Step 3: Register TON onto the previous instance
const wdk = wdk.registerWallet('ton', WalletManagerTon, {
tonClient: {
url: 'https://toncenter.com/api/v3',
secretKey: 'YOUR_API_KEY'
}
})
console.log('All chains registered')Both approaches are equivalent; use whichever is more readable for your codebase. The chained approach is more concise; the step-by-step approach makes each registration explicit.
Now retrieve an account and check its balance:
// Get the first account (index 0) on Ethereum
const ethAccount = await wdk.getAccount('ethereum', 0)
const ethAddress = await ethAccount.getAddress()
console.log('Ethereum address:', ethAddress)
// Get the balance
const balance = await ethAccount.getBalance()
console.log('Balance in wei:', balance)
console.log('Balance in ETH:', Number(balance) / 1e18)
// Get the first Bitcoin account
const btcAccount = await wdk.getAccount('bitcoin', 0)
const btcAddress = await btcAccount.getAddress()
console.log('Bitcoin address:', btcAddress) // Native SegWit (bech32)
// Get the first TON account
const tonAccount = await wdk.getAccount('ton', 0)
const tonAddress = await tonAccount.getAddress()
console.log('TON address:', tonAddress)What's happening here:
- The .getAccount(chainId, accountIndex) method derives an account from your seed phrase on the specified chain. The accountIndex (starting at 0) lets you create multiple accounts on the same chain from a single seed, useful for managing separate wallets or use cases.
- Once you have an account, you can:
- getAddress() gets the account's blockchain address (format depends on the chain)
- getBalance() fetches the native token balance (in wei for Ethereum, satoshis for Bitcoin, nanotons for TON)
- Access transaction methods (We'll see these in Step 3)
Every account has the same interface regardless of blockchain. No per-chain API differences to memorize.
Or, you can also retrieve accounts by a specific derivation path:
// Get a custom account using its derivation path
const customAccount = await wdk.getAccountByPath('ethereum', "0'/0/5")
const customAddress = await customAccount.getAddress()
console.log('Custom account:', customAddress)What's happening here:
Derivation paths are a standard method (BIP-44) for organizing accounts from a single seed phrase. The path "0'/0/5" means:
- 0’ First change level (typically external/public-facing accounts)
- 0Â Â Account index (allows multiple separate wallets from one seed)
- 5Â Â Address index within that account
The full path becomes m/44'/60'/0'/0/5 for Ethereum (the 44'/60' part is added automatically based on the chain). For Bitcoin, it would be m/84'/0'/0'/0/5 (Bitcoin uses a different path standard for native SegWit addresses). Each blockchain has its own path prefix; WDK handles this automatically.
When to use getAccountByPath():
- Recovering wallets:Â If you generated an account with another wallet tool and need to recreate it with WDK, use the same derivation path
- Non-sequential accounts: Instead of accounts 0, 1, 2, you can access accounts 5, 10, or any index directly
- Legacy wallet recovery: Different tools use different path conventions; getAccountByPath() lets you match them
For most use cases, getAccount(chainId, index) is simpler and automatically uses standard BIP-44 paths. Use getAccountByPath() only when you need custom path control.
Step 3: Build, Sign, and Send a Transaction
Now the moment you've been waiting for: actually sending a transaction. The API is consistent across chains, but the examples below show the reality of two different models, UTXO-based (Bitcoin) and account-based (Ethereum).
Bitcoin
// Get the balance first to ensure you have funds
const balance = await btcAccount.getBalance()
console.log('Balance:', balance, 'satoshis')
// Estimate the fee
const quote = await btcAccount.quoteSendTransaction({
to: 'bc1qemkknen39ftne2q00a5pk34s8sx7cz8elyjyds', // Recipient (bech32)
value: 50000n // Amount in satoshis
})
console.log('Estimated fee:', quote.fee, 'satoshis')
// Send the transaction
try {
const result = await btcAccount.sendTransaction({
to: 'bc1qemkknen39ftne2q00a5pk34s8sx7cz8elyjyds',
value: 50000n
})
console.log('Transaction hash:', result.hash)
console.log('Fee paid:', result.fee, 'satoshis')
} catch (error) {
console.error('Transaction failed:', error.message)
}What's happening here:
- Check balance first: Before attempting a transaction, verify the account has enough satoshis (1 BTC = 100,000,000 satoshis). Bitcoin uses a UTXO (Unspent Transaction Output) model, so you need sufficient UTXOs to cover the amount + fees.
- Estimate the fee: quoteSendTransaction() returns the estimated network fee without actually sending anything. This is crucial for Bitcoin because network fees vary based on congestion. Always estimate before sending to show users the actual cost.
- Send the transaction: sendTransaction() creates, signs, and broadcasts the transaction to the Bitcoin network. It returns:
- hash, the transaction ID you can look up on a block explorer
- fee, the actual fee paid (may differ slightly from the estimate due to network conditions)
- Error handling: Try/catch blocks catch common failures, such as insufficient balance, invalid addresses, network timeouts, or rejected transactions.
Ethereum / EVM Chains
// Estimate the fee first
const quote = await ethAccount.quoteSendTransaction({
to: '0x350188fB7EB1A49d43398b610a3714844b361FC3', // Recipient
value: 1000000000000000000n // 1 ETH in wei (BigInt)
})
console.log('Estimated fee:', quote.fee, 'wei')
console.log('Estimated fee in ETH:', Number(quote.fee) / 1e18)
// Send the transaction
try {
const result = await ethAccount.sendTransaction({
to: '0x350188fB7EB1A49d43398b610a3714844b361FC3',
value: 1000000000000000000n
})
console.log('Transaction hash:', result.hash)
console.log('Fee paid:', result.fee, 'wei')
} catch (error) {
console.error('Transaction failed:', error.message)
// Handle errors: insufficient balance, invalid recipient, network issues
}What's happening here:
- Estimate the fee first: on Ethereum, you don't need to check the balance beforehand like Bitcoin. Instead, estimate the gas fee upfront using quoteSendTransaction(). This shows users how much ETH will be consumed by gas. WDK handles EIP-1559 dynamic fee calculation automatically (base fee + priority fee).
- Send the transaction: sendTransaction() constructs, signs, and broadcasts the transaction to Ethereum. It returns:
- hash, The transaction hash (can be checked on Etherscan or another block explorer immediately)
- fee, the actual gas fee paid in wei
- Convert units for display: Number(quote.fee) / 1e18 converts wei to ETH for human readability. Always keep internal calculations in Wei (1 ETH = 10^18 Wei, much smaller than satoshis, so small that JavaScript's regular Number type loses precision. Always use BigInt) to avoid precision loss.
- Error handling: common issues are insufficient balance, invalid recipient address, nonce conflicts (if sending multiple transactions quickly), or network timeouts.
Why the code structure differs:
Bitcoin checks balance first (UTXO model), while Ethereum estimates fees first (account model). But both follow the same pattern: estimate → send → handle errors. WDK abstracts away these differences so you use the same mental model across chains.
What's next
In this post, we covered how to set up your wallet and perform your first transaction across different networks, and how to perform your first BTC & ETH transaction, In the next post Wallet Fundamentals: Sending USDt in WDK we will cover how to transfer USDt in different blockchains.
Receive the latest WDK news
Click to subscribe to the WDK newsletter and stay updated!
Latest Articles & Guides

Wallet Fundamentals: Sending USDt in WDK
Understand how token transfers work across different blockchain models and how WDK unifies them under a single developer experience.

Introduction to WDK: Tether's Wallet Development Kit
Understand what WDK is, why it was built, and the core concepts you need to start building with it
