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://
...
Top comments (0)