DUST Sponsorship on Midnight: How One Wallet Pays Fees for Another
The Problem: Users Without DUST Can't Transact
Midnight's privacy model relies on DUST — a shielded network resource, not a token, that pays for transaction fees. Every transaction consumes DUST. But here's the catch: new users start with zero DUST, and generating it requires holding NIGHT tokens through a multi-day lifecycle.
This creates a cold-start problem. How does a brand-new user submit their first transaction if they don't have DUST to pay fees?
The answer is DUST sponsorship: one wallet (the sponsor) pays the DUST fees for another user's transaction. This tutorial shows you exactly how.
Understanding DUST: Not a Token, a Resource
Before diving into sponsorship, you need to understand what DUST actually is.
DUST Is a Shielded Capacity Resource
DUST is generated by holding NIGHT tokens. It is not an ERC-20 token, not a fungible asset you can send between addresses. It's a capacity resource — like bandwidth allocation — that exists within the shielded context of a wallet.
Key parameters on the Preview network:
| Parameter | Value | Meaning |
|---|---|---|
night_dust_ratio |
5,000,000,000 | 5 DUST per NIGHT held |
generation_decay_rate |
8,267 | ~1 week to decay from max to zero |
dust_grace_period |
3 hours | Grace period after DUST hits zero |
The DUST Lifecycle
DUST follows four phases:
- Generating — DUST accumulates while NIGHT is held, growing toward a maximum proportional to your NIGHT balance.
- Constant (Max) — DUST has reached its cap and stays constant.
- Decaying — After NIGHT is moved or spent, DUST decays over approximately one week.
- Zero — DUST is fully depleted. A 3-hour grace period allows one final transaction.
Three Wallet Addresses
On the Preview network, each wallet has three addresses:
┌─────────────────────────────────┐
│ Wallet │
│ ├── Shielded Address │ ← Privacy transactions
│ ├── Unshielded Address │ ← Public transactions
│ └── DUST Address │ ← Fee capacity resource
└─────────────────────────────────┘
A new wallet has no DUST. Without sponsorship, it cannot submit any transaction that requires fees.
The Sponsorship Flow: Three Steps
DUST sponsorship uses a specific sequence of Midnight SDK calls. The core idea is to split transaction balancing into two phases: the user balances their own shielded/unshielded tokens, then the sponsor balances the DUST portion.
Step 1: User Creates and Balances Without DUST
The user creates their transaction and balances it, explicitly excluding DUST from the balancing scope:
// User creates the transaction
const transaction = await userWallet.transact()
// User balances only shielded and unshielded tokens
// The key parameter is tokenKindsToBalance — omit 'dust'
const userRecipe = await userWallet.balanceUnboundTransaction(
transaction,
{
shieldedSecretKeys: userShieldedKeys,
dustSecretKey: userDustKey,
},
{
ttl: new Date(Date.now() + 30 * 60 * 1000), // 30 min TTL
tokenKindsToBalance: ['shielded', 'unshielded'], // ← NO 'dust'
}
);
// User signs and finalizes their part
const userSigned = await userWallet.signRecipe(
userRecipe,
(payload) => userKeystore.signData(payload)
);
const userFinalized = await userWallet.finalizeRecipe(userSigned);
The tokenKindsToBalance parameter is the critical piece. By excluding 'dust', the user says "I'm not paying DUST fees — someone else will."
Step 2: Sponsor Adds DUST Fees
The sponsor takes the user's finalized transaction and adds DUST fees:
// Sponsor receives userFinalized from Step 1 (via API, message queue, etc.)
const sponsorRecipe = await sponsorWallet.balanceFinalizedTransaction(
userFinalized,
{
shieldedSecretKeys: sponsorShieldedKeys,
dustSecretKey: sponsorDustKey,
},
{
ttl: new Date(Date.now() + 30 * 60 * 1000),
tokenKindsToBalance: ['dust'], // ← ONLY 'dust'
}
);
// Sponsor signs and finalizes
const sponsorSigned = await sponsorWallet.signRecipe(
sponsorRecipe,
(payload) => sponsorKeystore.signData(payload)
);
const sponsorFinalized = await sponsorWallet.finalizeRecipe(sponsorSigned);
Here, tokenKindsToBalance: ['dust'] means the sponsor is only paying for the DUST portion. They are not re-balancing shielded or unshielded tokens.
Step 3: Sponsor Submits
The sponsor submits the fully balanced transaction to the network:
const txHash = await sponsorWallet.submitTransaction(sponsorFinalized);
console.log(`Transaction submitted: ${txHash}`);
The transaction is now fully balanced — the user's tokens are accounted for, and the sponsor's DUST covers the fees.
Sponsor Service Architecture
For production use, you need a sponsor service that receives user transactions, adds DUST fees, and submits them. Here's a complete architecture:
┌────────────┐ ┌──────────────────┐ ┌─────────────┐
│ User App │ ──POST──→│ Sponsor Service │ ──TX───→│ Midnight │
│ │ │ │ │ Network │
│ (no DUST) │←──200───│ (has DUST) │ │ │
└────────────┘ └──────────────────┘ └─────────────┘
│
├── balanceFinalizedTransaction
├── signRecipe
├── finalizeRecipe
└── submitTransaction
The sponsor service holds a wallet with sufficient DUST and exposes an API for users to submit their partially-balanced transactions.
Sponsor Service Implementation
import express from 'express';
import { Wallet, Resource } from '@midnight-ntwrk/wallet-api';
import { CoinPublicKey, EncPublicKey } from '@midnight-ntwrk/types';
const app = express();
app.use(express.json());
let sponsorWallet: Wallet & Resource;
let sponsorKeystore: { signData: (payload: Uint8Array) => Promise<Uint8Array> };
// Initialize sponsor wallet at startup
async function initSponsorWallet() {
// ... wallet initialization with NIGHT-holding seed phrase
// Ensure wallet has DUST capacity
}
// POST /sponsor — receive user's finalized transaction and sponsor it
app.post('/sponsor', async (req, res) => {
try {
const { userFinalized } = req.body;
// Step 2: Sponsor adds DUST fees
const sponsorRecipe = await sponsorWallet.balanceFinalizedTransaction(
userFinalized,
{
shieldedSecretKeys: sponsorShieldedKeys,
dustSecretKey: sponsorDustKey,
},
{
ttl: new Date(Date.now() + 30 * 60 * 1000),
tokenKindsToBalance: ['dust'],
}
);
const sponsorSigned = await sponsorWallet.signRecipe(
sponsorRecipe,
(payload) => sponsorKeystore.signData(payload)
);
const sponsorFinalized = await sponsorWallet.finalizeRecipe(sponsorSigned);
// Step 3: Submit
const txHash = await sponsorWallet.submitTransaction(sponsorFinalized);
res.json({ success: true, txHash });
} catch (error) {
console.error('Sponsorship failed:', error);
res.status(500).json({ success: false, error: String(error) });
}
});
app.listen(3001, () => console.log('Sponsor service running on port 3001'));
Advanced: Key Override Pattern
In some architectures, the sponsor wallet handles both balanceTx() and submitTx(), while a separate prover wallet generates the zero-knowledge proofs. This is the key override pattern.
How Key Override Works
When using key override, the sponsor wallet's balanceTx() and submitTx() calls use an external prover wallet for proof generation, while the sponsor wallet still handles the actual fee payment and submission.
// The prover's wallet — generates ZK proofs
let externalProverWallet: (Wallet & Resource) | null = null;
// Override keys pointing to the prover
let overrideKeys: {
coinPublicKey?: CoinPublicKey;
encryptionPublicKey?: EncPublicKey;
} = {};
// When the sponsor calls ownPublicKey(), it returns the PROVER's key
// because the override is active:
// ownPublicKey() → overrideKeys.coinPublicKey ?? wallet.coinPublicKey
Key Override in Practice
// Provider that routes proof generation to the external prover
const providerWithOverride = {
...baseProvider,
async proveTransaction(tx: Transaction) {
if (externalProverWallet) {
return externalProverWallet.proveTransaction(tx);
}
return baseProvider.proveTransaction(tx);
},
};
// The sponsor wallet uses this provider
// balanceTx() → calls providerWithOverride.proveTransaction()
// which routes to the external prover wallet
// submitTx() → uses the sponsor wallet's own submission path
Important: ownPublicKey() Behavior
When key override is active, ownPublicKey() returns the prover's coinPublicKey, not the sponsor's. This matters for:
- Address derivation — the transaction appears to come from the prover's address
- UTXO ownership — outputs are owned by the prover's keys
- Balance queries — must query the prover's address, not the sponsor's
// Without override:
wallet.ownPublicKey() // → wallet's own coinPublicKey
// With override:
wallet.ownPublicKey() // → overrideKeys.coinPublicKey (the prover's key)
Common Mistakes
Mistake 1: Balancing All Token Kinds at Once
// WRONG — this fails if the user has no DUST
const recipe = await userWallet.balanceUnboundTransaction(
transaction,
keys,
{ tokenKindsToBalance: ['shielded', 'unshielded', 'dust'] }
// ^^^^^^^^ user has no DUST!
);
Fix: The user must exclude 'dust' from their balancing. The sponsor adds it separately.
Mistake 2: Re-balancing Token Types the User Already Balanced
// WRONG — re-balances shielded/unshielded that the user already handled
const sponsorRecipe = await sponsorWallet.balanceFinalizedTransaction(
userFinalized,
keys,
{ tokenKindsToBalance: ['dust', 'shielded', 'unshielded'] }
// ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ unnecessary
);
Fix: The sponsor should only balance 'dust'. Re-balancing other token types can cause double-spending errors.
Mistake 3: Ignoring TTL Expiry
// WRONG — no TTL or TTL too short
const recipe = await sponsorWallet.balanceFinalizedTransaction(
userFinalized, keys, { tokenKindsToBalance: ['dust'] }
// No TTL! Transaction might expire before submission.
);
Fix: Always set a reasonable TTL (15–30 minutes) for both user and sponsor balancing steps.
Mistake 4: Assuming DUST Is a Transferable Token
DUST cannot be sent from one address to another like a regular token. It is generated from NIGHT holdings and consumed as a resource. There is no transferDUST() function. The only way to pay DUST for another user's transaction is through the sponsorship flow described above.
DUST Regeneration
When a sponsor's DUST is consumed, it regenerates over time based on their NIGHT holdings. Understanding the regeneration timeline helps size your sponsorship service:
DUST Level
│
Max ┤████████████████████████████████
│ ╲
│ ╲
│ ╲
│ ╲
│ ╲
0 ┤──────────────────────────────────────────────╳
│← Generation →← Constant →← Decay (≈1 week) →│Zero
│ │← Grace (3h)→│
Sizing Your Sponsor Wallet
- Preview network: 5 DUST per NIGHT per generation cycle
- Decay time: ~1 week from max to zero
- Each transaction consumes a small amount of DUST (typically 0.001–0.01 DUST)
- Rule of thumb: A wallet holding 100 NIGHT can sponsor approximately 50,000–500,000 transactions before needing regeneration
For a production sponsor service, hold enough NIGHT to generate DUST well above your projected transaction volume, and monitor DUST levels to replenish before hitting the decay phase.
Complete Working Example
See the companion files in src/:
-
sponsor-service.ts— Full sponsor service with Express API, wallet initialization, and sponsorship endpoint -
user-client.ts— Client-side code that creates a transaction, balances without DUST, and sends it to the sponsor -
sponsor-wallet.ts— Sponsor wallet setup with DUST monitoring and auto-replenishment
Quick Start
# Install dependencies
npm install @midnight-ntwrk/wallet-api @midnight-ntwrk/types express
# Set environment variables
export SPONSOR_SEED_PHRASE="your twelve word seed phrase here"
export NETWORK_ID="0" # Preview network
export RPC_URL="https://rpc.midnight.network"
export INDEXER_URL="https://indexer.midnight.network"
# Run the sponsor service
npx tsx src/sponsor-service.ts
Testing the Flow
// In a separate script or browser
import { createSponsorshipClient } from './user-client';
const client = createSponsorshipClient('http://localhost:3001');
// 1. User creates and balances their transaction (without DUST)
const userFinalized = await client.prepareTransaction();
// 2. Send to sponsor service for DUST sponsorship
const result = await client.sponsorTransaction(userFinalized);
console.log(`Transaction confirmed: ${result.txHash}`);
Security Considerations
Validate user transactions — Your sponsor service should verify that incoming transactions are legitimate before paying DUST fees. Implement rate limiting and transaction content validation.
Monitor DUST levels — A sponsor wallet can run out of DUST. Implement monitoring and alerting to ensure you always have sufficient DUST capacity.
Protect sponsor keys — The sponsor's seed phrase controls NIGHT holdings and DUST generation. Store it securely (environment variables, HSM, or secret management service).
Set transaction TTLs — Always set reasonable TTLs (15–30 minutes) to prevent stale transactions from consuming sponsor DUST.
Implement idempotency — Use transaction hashes to prevent duplicate sponsorship of the same transaction.
Summary
| Concept | Key Point |
|---|---|
| DUST | Shielded resource, not a token; generated from NIGHT |
| Cold-start problem | New users have no DUST to pay fees |
balanceUnboundTransaction |
User balances shielded/unshielded only |
balanceFinalizedTransaction |
Sponsor adds DUST fees |
tokenKindsToBalance |
Controls which token types each party balances |
| Key override | Prover wallet generates proofs; sponsor wallet pays and submits |
ownPublicKey() |
Returns prover's key when override is active |
| DUST regeneration | ~1 week decay; 3-hour grace period at zero |
| Production pattern | Sponsor service with Express API, monitoring, rate limiting |
DUST sponsorship solves the cold-start problem on Midnight. By splitting the balancing responsibility between user and sponsor, new wallets can transact immediately without needing their own NIGHT holdings or DUST generation capacity.
Top comments (0)