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: ·
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
to understand how DUST generation, decay, and the grace period work.
SOCIAL SHARE CARD GENERATOR