DUST sponsorship on Midnight lets a backend wallet pay transaction fees for users who do not have DUST yet. Every transaction on Midnight consumes DUST, a shielded, non-transferable capacity resource generated by holding NIGHT tokens. That design keeps fees predictable and privacy intact, but it creates onboarding friction: a brand-new wallet holds zero DUST. Without DUST, it cannot submit a single transaction, including the first one a user might want to make in your DApp.
DUST cannot be sent from one wallet to another. You cannot airdrop it, and there is no transferDUST() call.
The only way to cover fees for a wallet that has no DUST is through sponsorship. In this flow, a backend wallet with healthy DUST reserves pays the fees on behalf of the user without changing who signed the transaction or who owns its outputs.
Target audience: Developers building DApps on the Midnight network.
Prerequisites:
- Familiarity with TypeScript
- Basic understanding of the Midnight wallet SDK
- A Midnight wallet setup with access to a proof server
Related reading: DUST architecture · Wallet SDK · Getting started
What you'll have by the end: A working model for DUST sponsorship, including the two-phase balancing flow, an Express sponsor service, the ownPublicKey() behavior in sponsored transactions, and the DUST monitoring rules needed to keep the sponsor wallet funded.
What DUST actually is
Before writing a single line of code, it helps to be precise about what you are working with.
DUST is not a token. It does not appear on a block explorer as a transferable asset. Think of it as a capacity resource, like bandwidth, generated continuously from your NIGHT holdings. The Midnight docs describe the analogy well: NIGHT is the solar panel, DUST is the electricity it produces.
A few key parameters on the Preview Testnet set the shape of that lifecycle:
| Parameter | Value | What it means |
|---|---|---|
night_dust_ratio |
5,000,000,000 | 5 DUST generated per NIGHT held |
generation_decay_rate |
8,267 | ~1 week to decay from max to zero |
dust_grace_period |
3 hours | Final window after DUST hits zero |
The DUST lifecycle moves through four phases:
- Generating: DUST accumulates toward a cap proportional to your NIGHT balance.
- Constant: DUST sits at its maximum while NIGHT remains unspent.
- Decaying: once the backing NIGHT UTXO is spent, DUST decays to zero over roughly one week.
- Grace: a 3-hour grace period allows one final transaction even at zero.
This lifecycle is entirely shielded. DUST UTXOs use commitments and nullifiers, just like Zswap. Spending one consumes the old UTXO and creates a new one with the updated value minus the fee.
The sponsorship flow
The SDK splits transaction balancing into independent phases, controlled by the tokenKindsToBalance parameter on balanceUnboundTransaction. This is the mechanism sponsorship relies on.
The flow has three steps:
Step 1: The user balances their own tokens without DUST
The user calls balanceUnboundTransaction and explicitly excludes 'dust' from the tokenKindsToBalance array. This tells the SDK to settle shielded and unshielded inputs/outputs but leave the DUST portion open for someone else to fill.
import { WalletFacade } from '@midnight-ntwrk/wallet-sdk';
// userWallet is a WalletFacade instance, already synced
const transaction = await userWallet.transact(contractCall);
const userRecipe = await userWallet.balanceUnboundTransaction(
transaction,
{
shieldedSecretKeys: userShieldedKeys,
dustSecretKey: userDustKey,
},
{
ttl: new Date(Date.now() + 30 * 60 * 1000), // 30-minute TTL
tokenKindsToBalance: ['shielded', 'unshielded'], // 'dust' is deliberately omitted
}
);
// Sign and finalize the user's portion
const userSigned = await userWallet.signRecipe(
userRecipe,
(payload) => userKeystore.signData(payload)
);
const userFinalized = await userWallet.finalizeRecipe(userSigned);
// Send userFinalized to the sponsor service
const response = await fetch('https://your-sponsor-service/sponsor', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ userFinalized }),
});
const { txHash } = await response.json();
console.log(`Transaction confirmed: ${txHash}`);
The tokenKindsToBalance parameter is the key piece here. By leaving 'dust' out, the user is saying: "I'm settling my own tokens, but I'm not covering the fee. The sponsor will." The finalized transaction is complete from the user's perspective but is not yet submittable.
Step 2: The sponsor adds DUST fees
The sponsor service receives userFinalized and calls balanceFinalizedTransaction with tokenKindsToBalance: ['dust']. This time only DUST is balanced. The sponsor is not touching the user's shielded or unshielded tokens.
// sponsorWallet is a WalletFacade instance with healthy DUST reserves
const sponsorRecipe = await sponsorWallet.balanceFinalizedTransaction(
userFinalized,
{
shieldedSecretKeys: sponsorShieldedKeys,
dustSecretKey: sponsorDustKey,
},
{
ttl: new Date(Date.now() + 30 * 60 * 1000),
tokenKindsToBalance: ['dust'], // only DUST
}
);
const sponsorSigned = await sponsorWallet.signRecipe(
sponsorRecipe,
(payload) => sponsorKeystore.signData(payload)
);
const sponsorFinalized = await sponsorWallet.finalizeRecipe(sponsorSigned);
Step 3: The sponsor submits
const txHash = await sponsorWallet.submitTransaction(sponsorFinalized);
console.log(`Submitted: ${txHash}`);
The transaction is now fully balanced. The user's tokens are accounted for, the sponsor's DUST covers the fee, and the combined transaction hits the network.
Full sponsor service implementation
Here is a complete Express-based sponsor service you can adapt for production.
// sponsor-service.ts
import express from 'express';
import { WalletFacade } from '@midnight-ntwrk/wallet-sdk';
import type { FinalizedTransaction } from '@midnight-ntwrk/wallet-api';
const app = express();
app.use(express.json({ limit: '1mb' }));
// Sponsor wallet state
let sponsorWallet: WalletFacade;
let sponsorShieldedKeys: { secretKey: Uint8Array };
let sponsorDustKey: { secretKey: Uint8Array };
let sponsorKeystore: { signData: (payload: Uint8Array) => Promise<Uint8Array> };
async function initSponsorWallet(): Promise<void> {
const seedPhrase = process.env.SPONSOR_SEED_PHRASE;
if (!seedPhrase) throw new Error('SPONSOR_SEED_PHRASE is required');
// Initialize the wallet facade with Preview Testnet config
sponsorWallet = await WalletFacade.create({
seedPhrase,
networkId: process.env.NETWORK_ID ?? '0',
rpcUrl: process.env.RPC_URL ?? 'https://rpc.midnight.network',
indexerUrl: process.env.INDEXER_URL ?? 'https://indexer.midnight.network',
proofServerUrl: process.env.PROOF_SERVER_URL ?? 'http://localhost:6300',
});
// Wait until the wallet has synced to the chain tip
await sponsorWallet.waitForSync();
// Derive keys. These come from your wallet initialization flow.
sponsorShieldedKeys = { secretKey: sponsorWallet.shieldedSecretKey };
sponsorDustKey = { secretKey: sponsorWallet.dustSecretKey };
sponsorKeystore = {
signData: (payload) => sponsorWallet.signWithUnshieldedKey(payload),
};
const state = await sponsorWallet.getState();
console.log(`Sponsor wallet ready. DUST balance: ${state.dust.available}`);
}
// DUST monitoring
const DUST_LOW_WATERMARK = 100n; // alert threshold in DUST units
async function checkDustLevel(): Promise<void> {
const state = await sponsorWallet.getState();
const available = state.dust.available;
if (available < DUST_LOW_WATERMARK) {
console.warn(
`Sponsor DUST low: ${available}. ` +
`Add NIGHT to the sponsor wallet to regenerate.`
);
// In production: trigger a PagerDuty alert, Slack message, etc.
}
}
// Sponsorship endpoint
app.post('/sponsor', async (req, res) => {
const { userFinalized } = req.body as { userFinalized: FinalizedTransaction };
if (!userFinalized) {
return res.status(400).json({ error: 'userFinalized is required' });
}
try {
await checkDustLevel();
// Balance only the DUST portion. The user already balanced everything else.
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);
// Submit the fully balanced transaction
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) });
}
});
// Health check
app.get('/health', async (_req, res) => {
const state = await sponsorWallet.getState();
res.json({ dust: String(state.dust.available), synced: state.isSynced });
});
// Startup
initSponsorWallet()
.then(() => {
app.listen(3001, () =>
console.log('Sponsor service running on port 3001')
);
})
.catch((err) => {
console.error('Failed to initialize sponsor wallet:', err);
process.exit(1);
});
Environment variables:
export SPONSOR_SEED_PHRASE="your twelve word seed phrase here"
export NETWORK_ID="0" # Preview Testnet
export RPC_URL="https://rpc.midnight.network"
export INDEXER_URL="https://indexer.midnight.network"
export PROOF_SERVER_URL="http://localhost:6300"
Install dependencies:
npm install @midnight-ntwrk/wallet-sdk @midnight-ntwrk/wallet-api express
npm install --save-dev @types/express tsx typescript
Run the service:
npx tsx src/sponsor-service.ts
ownPublicKey() in sponsored transactions
One question comes up consistently when developers first work with sponsored transactions: whose key does ownPublicKey() return when the sponsor wallet is doing the balancing?
The answer is the prover's key, always. This is by design.
ownPublicKey() reflects whoever generated the ZK proof for the transaction, not whoever paid the DUST fee. In a standard sponsored flow, the user proves their own transaction before sending it to the sponsor, so ownPublicKey() returns the user's coinPublicKey. The sponsor's identity never leaks into the transaction's cryptographic identity.
This matters for three reasons:
- Address derivation: outputs from the transaction are owned by the prover's address, not the sponsor's.
- UTXO ownership: the wallet that can later spend those outputs is the prover's wallet.
- Balance queries: if you are tracking state after the transaction, query the prover's address.
Some advanced architectures use an explicit key override pattern, where a separate prover wallet generates ZK proofs and the sponsor handles only fee payment and submission. In that configuration, the override is active at balance time:
// With key override active on the sponsor wallet:
sponsorWallet.ownPublicKey();
// → returns overrideKeys.coinPublicKey (the prover's key)
// → NOT the sponsor wallet's own coinPublicKey
The key override is useful when you have a dedicated proof-generation service that is separate from your fee-paying backend. The prover wallet handles cryptographic identity and proof generation; the sponsor wallet handles DUST and submission. ownPublicKey() always reflects the prover, regardless of which wallet calls it.
DUST regeneration vs depletion
Understanding the regeneration curve is essential for sizing your sponsor wallet correctly. Once you know the math, operating a sponsor service becomes straightforward.
The regeneration curve
While NIGHT remains unspent, DUST generates toward its cap and stays there. The moment a backing NIGHT UTXO is spent, for example, if the sponsor wallet moves NIGHT to another address, the associated DUST begins decaying. The decay takes roughly one week.
Sizing your sponsor wallet
| Variable | Value (Preview Testnet) |
|---|---|
| DUST generated per NIGHT | 5 DUST per generation cycle |
| Typical fee per transaction | ~0.001–0.01 DUST |
| Decay time (max to zero) | ~1 week |
| Grace period at zero | 3 hours |
As a rule of thumb: a wallet holding 100 NIGHT can sponsor tens of thousands of transactions before you need to think about replenishment. In practice, the constraint is more likely to be DUST decay from NIGHT movement than raw transaction volume.
Keeping the sponsor wallet healthy
Two practices keep a production sponsor service running smoothly:
Do not move NIGHT unless you need to. Every time you spend a backing NIGHT UTXO, even to consolidate, the associated DUST starts decaying. If you need to move NIGHT, do it during low-traffic periods and ensure the new NIGHT UTXO has time to build up DUST before the old one's decay expires.
Monitor DUST levels actively. The health endpoint in the service above returns the current DUST balance. Hook it up to whatever alerting you use, such as Datadog, PagerDuty, or a simple cron + Slack webhook, and set a low-watermark alert well before you hit the decay phase.
// Simple cron-style monitoring
setInterval(async () => {
const state = await sponsorWallet.getState();
const dustAvailable = state.dust.available;
if (dustAvailable < DUST_LOW_WATERMARK) {
await sendAlert(`Sponsor DUST at ${dustAvailable}. Replenish NIGHT.`);
}
}, 5 * 60 * 1000); // check every 5 minutes
Common mistakes
Mistake 1: Including 'dust' in the user's tokenKindsToBalance
// WRONG: fails if the user has no DUST
const recipe = await userWallet.balanceUnboundTransaction(
transaction,
keys,
{ tokenKindsToBalance: ['shielded', 'unshielded', 'dust'] }
// ^^^^^ user has no DUST
);
The fix: always omit 'dust' from the user's step and let the sponsor add it separately.
Mistake 2: The sponsor re-balances token types the user already settled
// WRONG: causes double-spend errors
const sponsorRecipe = await sponsorWallet.balanceFinalizedTransaction(
userFinalized,
keys,
{ tokenKindsToBalance: ['dust', 'shielded', 'unshielded'] }
// ^^^^^^^^^^^^^^^^^^^^^^^^^ already done
);
The fix: the sponsor's tokenKindsToBalance should be ['dust'] only.
Mistake 3: No TTL set
// WRONG: transaction may expire before submission
const recipe = await sponsorWallet.balanceFinalizedTransaction(
userFinalized,
keys,
{ tokenKindsToBalance: ['dust'] }
// missing ttl
);
The fix: always pass a TTL of 15–30 minutes for both the user step and the sponsor step. Without it, network congestion or a slow proof server can leave you with an expired transaction and a consumed DUST UTXO.
Mistake 4: Treating DUST as a transferable token
There is no transferDUST() call. DUST is generated from NIGHT and consumed as a resource. It exists only within the shielded context of the wallet that holds the corresponding NIGHT. The sponsorship flow described above is the only way to cover fees for another wallet.
Summary
| Concept | Key point |
|---|---|
| DUST | Shielded capacity resource, not a token; generated from NIGHT holdings |
| Cold-start problem | New wallets have zero DUST and cannot submit transactions |
balanceUnboundTransaction |
User balances ['shielded', 'unshielded'] and omits 'dust'
|
balanceFinalizedTransaction |
Sponsor adds ['dust'] only |
tokenKindsToBalance |
Controls which token types each party settles |
ownPublicKey() |
Always returns the prover's key, not the sponsor's |
| DUST regeneration | Grows while NIGHT is held; decays ~1 week after NIGHT is spent |
| Grace period | 3 hours after DUST hits zero |
| Production pattern | Express sponsor service + DUST monitoring + TTL on every step |
DUST sponsorship solves the onboarding problem: new users can transact from their first session while your backend absorbs the fee cost. Once you understand the two-phase balancing approach, the implementation is straightforward. The operational overhead stays low as long as you monitor your sponsor wallet's DUST levels.
Next steps
You now have the sponsorship pattern and service structure. From here:
- Review the DUST architecture guide to understand how DUST generation, decay, and the grace period work.
- Use the Wallet SDK reference to wire sponsorship into your own wallet flow.
- Join the Midnight developer forum or Discord if you hit implementation issues.


Top comments (0)