DUST sponsorship: How one wallet pays fees for another user's transaction
Every transaction on Midnight consumes DUST. New users start with a DUST balance of zero, which creates a problem; how can they submit transactions if they cannot pay fees, especially the transactions needed to acquire NIGHT tokens in the first place?
DUST sponsorship solves this. A sponsor (typically a backend wallet) that already holds NIGHT tokens and has accumulated DUST covers the fee portion of another user's transaction. The user's keys sign the user's assets; the sponsor's keys sign only the DUST portion. The two halves are joined into one valid, on-chain transaction.
This tutorial walks through the complete sponsorship flow:
- How
tokenKindsToBalancesplits fee responsibility between the user and the sponsor - A full sponsor service implementation
- How DUST regenerates and depletes
Before you begin
Make sure you are comfortable with the following:
- Midnight dev setup: You know how to set it up and use its basic features. View the Midnight developer tutorial.
- TypeScript: You understand types, functions, and async operations.
- DUST and NIGHT: You understand the basic roles of DUST and NIGHT in Midnight transactions and network operations. Read the DUST and NIGHT overview.
Understanding DUST
A common mistake is treating DUST as a token. You cannot buy it on an exchange or send it between addresses. DUST is a shielded resource that:
- Accumulates continuously while you hold NIGHT tokens
- Gets consumed each time you submit a transaction that requires fees
- Decays over approximately one week if you move your NIGHT tokens away
- Cannot be transferred directly from one address to another. The sponsorship flow described in this tutorial is the only mechanism to pay fees on behalf of another wallet
The key parameters on the Preview network are:
| Parameter | Value | What it means |
|---|---|---|
night_dust_ratio |
5,000,000,000 | Maximum of 5 DUST per NIGHT held |
generation_decay_rate |
8,267 | Approximately one week to generate from zero to maximum, or decay from maximum to zero |
dust_grace_period |
3 hours | Timestamp tolerance window that allows back-dated DUST spends within 3 hours of the current block time |
Each wallet has three distinct addresses: a shielded address, an unshielded address, and a DUST address. The DUST address starts empty. Transactions cannot proceed until DUST is present or a sponsor covers the fees.
How sponsorship works
Sponsorship splits the transaction balancing process into two scoped operations:
- The user balances their own shielded and unshielded token inputs, explicitly excluding DUST.
- The sponsor takes the user's partially balanced transaction and adds DUST fees from their own DUST balance.
The sponsor then submits the fully balanced transaction to the network. The tokenKindsToBalance parameter makes this split possible: it controls which resource type each party handles.
The user balances without DUST
Call balanceUnboundTransaction on the user's wallet, excluding 'dust' from tokenKindsToBalance.
// user-side.ts
import { WalletFacade } from "@midnight-ntwrk/wallet-sdk-facade";
import type { UnboundTransaction } from "@midnight-ntwrk/ledger-v8";
async function prepareUserTransaction(
userWallet: WalletFacade,
transaction: UnboundTransaction, // built from your contract call or transfer intent
userShieldedKeys: Uint8Array[],
userDustKey: Uint8Array,
userKeystore: { signData: (payload: Uint8Array) => Promise<Uint8Array> }
) {
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' deliberately omitted
}
);
const userSigned = await userWallet.signRecipe(userRecipe, (payload) =>
userKeystore.signData(payload)
);
return await userWallet.finalizeRecipe(userSigned);
}
The SDK processes shielded and unshielded inputs but leaves the fee slot unpopulated (this is intentional). signRecipe and finalizeRecipe seal the user's cryptographic commitments into a serialisable object. Once sealed, neither the sponsor nor any other party can alter the asset portion without invalidating those commitments. The 30-minute TTL applies to the entire flow: if the sponsor does not submit within that window, the network rejects the transaction. Pass the return value of finalizeRecipe to your sponsor service as the userFinalized payload.
Note: You still need to pass dustSecretKey even though 'dust' is excluded from tokenKindsToBalance. The SDK requires it for internal bookkeeping and fee estimation. Excluding 'dust' from tokenKindsToBalance prevents it from attempting to draw from an empty balance.
The sponsor adds DUST fees
Call balanceFinalizedTransaction on the sponsor's wallet, restricting tokenKindsToBalance to ['dust'].
// sponsor-side.ts
import { WalletFacade } from "@midnight-ntwrk/wallet-sdk-facade";
import type { FinalizedTransaction } from "@midnight-ntwrk/ledger-v8";
async function sponsorTransaction(
sponsorWallet: WalletFacade,
sponsorShieldedKeys: Uint8Array[],
sponsorDustKey: Uint8Array,
sponsorKeystore: { signData: (payload: Uint8Array) => Promise<Uint8Array> },
userFinalized: FinalizedTransaction // received from the user via API
) {
const sponsorRecipe = await sponsorWallet.balanceFinalizedTransaction(
userFinalized,
{
shieldedSecretKeys: sponsorShieldedKeys,
dustSecretKey: sponsorDustKey,
},
{
ttl: new Date(Date.now() + 30 * 60 * 1000),
tokenKindsToBalance: ["dust"], // sponsor handles DUST only
}
);
const sponsorSigned = await sponsorWallet.signRecipe(
sponsorRecipe,
(payload) => sponsorKeystore.signData(payload)
);
const sponsorFinalized = await sponsorWallet.finalizeRecipe(sponsorSigned);
return await sponsorWallet.submitTransaction(sponsorFinalized);
}
balanceFinalizedTransaction receives a transaction that already carries the user's zero-knowledge proofs and asset commitments; it adds DUST inputs on top without touching the existing proof data. Restricting tokenKindsToBalance to ['dust'] is not optional; including 'shielded' or 'unshielded' would attempt to re-balance inputs the user already committed, causing a double-spending error. The signRecipe → finalizeRecipe → submitTransaction chain seals the sponsor's DUST contribution and broadcasts the transaction as a single atomic operation. The network sees one transaction, not two.
How sponsorship preserves proof identity
A common concern is whether the sponsor's key ends up attached to the user's transaction. It does not.
Each party generates zero-knowledge proofs only for their own part of the transaction. The user's proofs are generated locally, using the user's own keys, before userFinalized leaves the client. By the time the sponsor receives it, those proofs are cryptographically sealed: signRecipe and finalizeRecipe lock the user's asset commitments so that no third party can alter or re-sign them without invalidating the entire proof. The sponsor generates a separate proof covering only the DUST spend, signed with the sponsor's own key.
If your Compact smart contract calls ownPublicKey(); a circuit witness function that returns the public key of whoever is running the proof locally, it will always resolve to the user's key, not the sponsor's. The sponsor never touches the user's circuit execution. The final on-chain transaction carries two distinct cryptographic domains: the user's asset proofs attributed to the user's key, and the sponsor's DUST proof attributed to the sponsor's key. Privacy is preserved for both parties.
Full sponsor service implementation
Wire the wallet lifecycle, the sponsorship endpoint, rate limiting, and DUST monitoring into a single Express application.
// sponsor-service.ts
import express from "express";
import * as bip39 from "bip39";
import * as ledger from "@midnight-ntwrk/ledger-v8";
import {
WalletFacade,
type DefaultConfiguration,
} from "@midnight-ntwrk/wallet-sdk-facade";
import { ShieldedWallet } from "@midnight-ntwrk/wallet-sdk-shielded";
import { DustWallet } from "@midnight-ntwrk/wallet-sdk-dust-wallet";
import {
UnshieldedWallet,
createKeystore,
PublicKey,
InMemoryTransactionHistoryStorage,
} from "@midnight-ntwrk/wallet-sdk-unshielded-wallet";
import {
HDWallet,
Roles,
type AccountKey,
} from "@midnight-ntwrk/wallet-sdk-hd";
import { Buffer } from "buffer";
const app = express();
app.use(express.json({ limit: "2mb" }));
let sponsorWallet: WalletFacade | null = null;
let sponsorShieldedKeys: Uint8Array[] = [];
let sponsorDustKey: Uint8Array;
let sponsorKeystore: {
signData: (payload: Uint8Array) => Promise<Uint8Array>;
};
function deriveRoleKey(
accountKey: AccountKey,
role: number,
index = 0
): Buffer {
const result = accountKey.selectRole(role).deriveKeyAt(index);
if (result.type === "keyDerived") return Buffer.from(result.key);
return deriveRoleKey(accountKey, role, index + 1);
}
async function initSponsorWallet() {
const seedPhrase = process.env.SPONSOR_SEED_PHRASE;
if (!seedPhrase) throw new Error("SPONSOR_SEED_PHRASE is required");
const networkId = process.env.NETWORK_ID ?? "preview";
const rpcUrl = process.env.RPC_URL ?? "wss://rpc.midnight.network";
const indexerUrl =
process.env.INDEXER_URL ?? "https://indexer.midnight.network";
const proofServerUrl =
process.env.PROOF_SERVER_URL ?? "http://localhost:6300";
const seed = bip39.mnemonicToSeedSync(seedPhrase);
const hdWallet = HDWallet.fromSeed(seed);
if (hdWallet.type !== "seedOk")
throw new Error("Failed to derive keys from seed phrase");
const account = hdWallet.hdWallet.selectAccount(0);
const shieldedSeed = deriveRoleKey(account, Roles.Zswap);
const dustSeed = deriveRoleKey(account, Roles.Dust);
const unshieldedKey = deriveRoleKey(account, Roles.NightExternal);
hdWallet.hdWallet.clear();
const shieldedKeys = ledger.ZswapSecretKeys.fromSeed(shieldedSeed);
const dustKey = ledger.DustSecretKey.fromSeed(dustSeed);
const unshieldedKeystore = createKeystore(unshieldedKey, networkId);
const configuration: DefaultConfiguration = {
networkId,
costParameters: { feeBlocksMargin: 5 },
relayURL: new URL(rpcUrl),
provingServerUrl: new URL(proofServerUrl),
indexerClientConnection: {
indexerHttpUrl: `${indexerUrl}/api/v4/graphql`,
indexerWsUrl: `${indexerUrl.replace(
"https://",
"wss://"
)}/api/v4/graphql/ws`,
},
txHistoryStorage: new InMemoryTransactionHistoryStorage(),
};
sponsorWallet = await WalletFacade.init({
configuration,
shielded: (config) =>
ShieldedWallet(config).startWithSecretKeys(shieldedKeys),
unshielded: (config) =>
UnshieldedWallet(config).startWithPublicKey(
PublicKey.fromKeyStore(unshieldedKeystore)
),
dust: (config) =>
DustWallet(config).startWithSecretKey(
dustKey,
ledger.LedgerParameters.initialParameters().dust
),
});
await sponsorWallet.start(shieldedKeys, dustKey);
sponsorShieldedKeys = shieldedKeys as unknown as Uint8Array[];
sponsorDustKey = dustKey as unknown as Uint8Array;
sponsorKeystore = unshieldedKeystore;
const state = await sponsorWallet.waitForSyncedState();
console.log("Sponsor wallet initialized");
console.log("Sponsor DUST address:", state.dust.address);
await checkDustLevel();
}
async function checkDustLevel() {
if (!sponsorWallet) return;
const state = await sponsorWallet.waitForSyncedState();
const dustBalance = state.dust.totalCoins;
const LOW_DUST_THRESHOLD = 100;
if (dustBalance < LOW_DUST_THRESHOLD) {
console.warn(
`[WARN] Sponsor DUST balance is low: ${dustBalance}. Top up NIGHT holdings.`
);
} else {
console.log(`Sponsor DUST balance: ${dustBalance}`);
}
}
// Simple in-memory rate limiter; use Redis for production
const requestCounts = new Map<string, { count: number; resetAt: number }>();
const RATE_LIMIT = 10;
const RATE_WINDOW_MS = 60_000;
function isRateLimited(ip: string): boolean {
const now = Date.now();
const entry = requestCounts.get(ip);
if (!entry || now > entry.resetAt) {
requestCounts.set(ip, { count: 1, resetAt: now + RATE_WINDOW_MS });
return false;
}
entry.count += 1;
return entry.count > RATE_LIMIT;
}
const processedTxIds = new Set<string>();
app.post("/sponsor", async (req, res) => {
const clientIp = req.ip ?? "unknown";
if (isRateLimited(clientIp)) {
return res
.status(429)
.json({ success: false, error: "Rate limit exceeded" });
}
const { userFinalized, idempotencyKey } = req.body;
if (!userFinalized) {
return res
.status(400)
.json({ success: false, error: "Missing userFinalized" });
}
if (idempotencyKey && processedTxIds.has(idempotencyKey)) {
return res
.status(200)
.json({ success: true, message: "Already processed" });
}
if (!sponsorWallet) {
return res
.status(503)
.json({ success: false, error: "Sponsor wallet not ready" });
}
try {
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);
const txHash = await sponsorWallet.submitTransaction(sponsorFinalized);
if (idempotencyKey) processedTxIds.add(idempotencyKey);
console.log(`Sponsored transaction submitted: ${txHash}`);
await checkDustLevel();
return res.json({ success: true, txHash });
} catch (error) {
console.error("Sponsorship failed:", error);
return res.status(500).json({ success: false, error: String(error) });
}
});
app.get("/health", async (_req, res) => {
if (!sponsorWallet) {
return res.status(503).json({ status: "not ready" });
}
const state = await sponsorWallet.waitForSyncedState();
return res.json({ status: "ok", dustBalance: state.dust.totalCoins });
});
const PORT = parseInt(process.env.PORT ?? "3001", 10);
initSponsorWallet()
.then(() => {
app.listen(PORT, () =>
console.log(`Sponsor service running on port ${PORT}`)
);
})
.catch((err) => {
console.error("Failed to initialize sponsor wallet:", err);
process.exit(1);
});
HDWallet.fromSeed() derives three independent key sets from a single mnemonic using BIP-44 role paths. hdWallet.clear() is called immediately after to prevent the master seed from remaining in memory. WalletFacade.init() starts all three sub-wallets against the same network configuration, and start() triggers chain synchronisation. waitForSyncedState() then blocks until the wallet has caught up before the service begins accepting requests. The in-memory rate limiter and idempotency set are sufficient for a single-instance deployment. Replace both with Redis before running at scale. DUST balance is checked after every successful sponsorship so the service warns before the wallet runs dry rather than discovering the problem mid-request.
Environment variables
Set these environment variables before starting the service.
SPONSOR_SEED_PHRASE="your twelve word seed phrase here"
NETWORK_ID="preview"
RPC_URL="wss://rpc.midnight.network"
INDEXER_URL="https://indexer.midnight.network"
PROOF_SERVER_URL="http://localhost:6300"
PORT="3001"
NETWORK_ID must be the string identifier ('preview' or 'preprod'), not a number, because WalletFacade uses it for address encoding and network routing. RPC_URL requires a WebSocket URL (wss://) rather than HTTPS. The SDK maintains a persistent relay connection rather than making individual HTTP calls. PROOF_SERVER_URL points to a separately running proving server process; the service will not start if this process is not already available.
User client
This is the function your DApp calls to balance a transaction locally and hand the result to the sponsor service.
// user-client.ts
import { WalletFacade } from "@midnight-ntwrk/wallet-sdk-facade";
import type { UnboundTransaction } from "@midnight-ntwrk/ledger-v8";
const SPONSOR_URL = process.env.SPONSOR_URL ?? "http://localhost:3001";
export async function runSponsoredTransaction(
userWallet: WalletFacade,
transaction: UnboundTransaction, // built from your contract call or transfer intent
userShieldedKeys: Uint8Array[],
userDustKey: Uint8Array,
userKeystore: { signData: (payload: Uint8Array) => Promise<Uint8Array> }
): Promise<string> {
const userRecipe = await userWallet.balanceUnboundTransaction(
transaction,
{ shieldedSecretKeys: userShieldedKeys, dustSecretKey: userDustKey },
{
ttl: new Date(Date.now() + 30 * 60 * 1000),
tokenKindsToBalance: ["shielded", "unshielded"],
}
);
const userSigned = await userWallet.signRecipe(userRecipe, (payload) =>
userKeystore.signData(payload)
);
const userFinalized = await userWallet.finalizeRecipe(userSigned);
const idempotencyKey = crypto.randomUUID();
const response = await fetch(`${SPONSOR_URL}/sponsor`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ userFinalized, idempotencyKey }),
});
const result = await response.json();
if (!result.success) {
throw new Error(`Sponsorship failed: ${result.error}`);
}
return result.txHash;
}
The transaction parameter must be fully constructed before calling this function: it holds the contract call or transfer intent and is not modified here. The user's asset commitments are locked in during signRecipe. By the time userFinalized leaves the client, its asset portion is immutable and cannot be altered by the sponsor. A fresh idempotencyKey per transaction (not per session) prevents the sponsor from processing duplicate submissions if the network call is retried. The function throws a descriptive error if the sponsor rejects the transaction rather than returning silently.
DUST regeneration and depletion
To maintain a sponsor wallet efficiently, you need to understand the DUST lifecycle.
- Generating: DUST accumulates towards a cap proportional to your NIGHT balance. The more NIGHT you hold, the higher the cap.
- Constant: DUST sits at its maximum. Sponsorships draw from this pool while NIGHT holdings continuously replenish it.
- Decaying: DUST begins to decay when NIGHT tokens leave the wallet. The decay period takes approximately one week to fall from maximum to zero.
- Grace period: The protocol accepts DUST spends whose timestamp is within 3 hours of the current block time. This means you can submit transactions using the DUST value from up to 3 hours ago. Once that 3-hour window has elapsed with zero DUST, the wallet cannot transact until DUST regenerates.
Sizing your sponsor wallet
On the Preview network:
- The maximum DUST balance is 5 DUST per NIGHT held
- Transaction fees are dynamic and adjust based on network utilization and transaction complexity. There is no fixed per-transaction cost
- Hold more NIGHT than your projected daily volume requires, since fee pressure increases as block utilization rises
For production deployments:
- Hold significantly more NIGHT than your projected daily transaction volume requires.
- Check
state.dust.totalCoinsviawaitForSyncedState()after each sponsorship, as shown in the service above. - Alert when DUST drops below a safe threshold. Do not wait for it to reach zero.
- Consider multiple sponsor wallets for high-volume DApps, distributing load across separate NIGHT holdings.
What happens when DUST runs out
If the sponsor's wallet is fully exhausted and the grace period has passed, balanceFinalizedTransaction throws an error when tokenKindsToBalance includes 'dust'. The transaction cannot be submitted.
Catch this error in your service, return a clear error response to the user, and alert your operations team to top up NIGHT holdings or switch to a backup sponsor wallet.
Common mistakes
Balancing all token types as the user
This is what happens when 'dust' is included in the user's tokenKindsToBalance.
// Wrong: fails immediately if the user has no DUST
await userWallet.balanceUnboundTransaction(transaction, keys, {
tokenKindsToBalance: ["shielded", "unshielded", "dust"],
});
The SDK attempts to draw from the user's DUST balance to cover fees, finds nothing, and throws before any network interaction occurs. The error message may not explicitly name DUST as the cause; tokenKindsToBalance should be the first thing you check when debugging a failed balance call. Remove 'dust' from this array and let the sponsor add it separately via balanceFinalizedTransaction.
Rebalancing token types the user already balanced
This is what happens when the sponsor re-includes token types the user already balanced.
// Wrong: causes double-spending errors
await sponsorWallet.balanceFinalizedTransaction(userFinalized, keys, {
tokenKindsToBalance: ["dust", "shielded", "unshielded"],
});
By the time the sponsor receives userFinalized, the shielded and unshielded inputs are cryptographically committed. Balancing them again creates conflicting spend proofs. The network rejects this as a double-spending attempt, which surfaces as an error during submission rather than during the balance step, making it harder to diagnose. Restrict the sponsor's call to ['dust'] only.
Setting a TTL that is too short
Both the user and sponsor calls must include a TTL long enough for the full round trip: user → sponsor → Midnight network → response. A minimum of 15 minutes is reasonable; 30 minutes is safer. If the round trip exceeds the TTL, the network rejects the transaction.
Assuming DUST is transferable
There is no way to transfer DUST directly between addresses, no market to buy it, and no shortcut. The sponsorship flow described in this tutorial is the only mechanism to pay fees on behalf of another wallet.
Summary
DUST sponsorship solves the cold-start problem without compromising Midnight's privacy model. The user's keys sign only the user's assets, and the sponsor's keys sign only the DUST fee. No private data crosses the boundary, and the transaction appears on-chain as a single, fully valid operation.
Top comments (0)