DEV Community

Tosh
Tosh

Posted on

DUST Sponsorship on Midnight: How One Wallet Pays Fees for Another

DUST Sponsorship on Midnight: Onboarding Users Who Have Nothing

The hardest part of onboarding new users to any blockchain is the bootstrapping problem: to do anything on chain, they need gas. But to get gas, they need to have already done something on chain. On Ethereum, various meta-transaction standards emerged to solve this. On Midnight, the mechanism is DUST sponsorship.

This guide covers how DUST works, how to build a sponsor service in TypeScript, the specific API quirk that trips everyone up the first time, and how to handle DUST regeneration so your sponsor account doesn't run dry.


What DUST Is

DUST is Midnight's fee token. Every transaction consumes DUST regardless of what else the transaction does — private token transfers, contract calls, shielding operations, all of it. There's no gas-free path on Midnight.

Unlike NIGHT (Midnight's main token), DUST is always public. There's no private DUST. The chain needs to see DUST inputs and outputs to validate fee payment, which means DUST balances are visible on-chain. This is a deliberate design choice: fee payment is a public commitment to the network.

DUST also regenerates. When you send a transaction, the DUST you spend doesn't disappear permanently — some of it comes back as a "DUST reward" after the transaction is included in a block. The regenerated amount is less than what you spent, so DUST still has a net cost, but the regeneration mechanism means a sponsor account can sustain ongoing operations without being constantly topped up.


The Sponsorship Model

A sponsor is any wallet that provides DUST inputs to cover another user's transaction fees. The user submits a transaction that has no DUST input; the sponsor adds DUST to make it valid; the combined transaction goes on chain.

The mechanics in the Midnight SDK involve balanceUnboundTransaction. This function takes a partially-assembled transaction and figures out what DUST inputs are needed to make it balance. In the sponsorship case, you call it with tokenKindsToBalance: ["dust"] to tell the SDK to balance only the DUST portion using the sponsor's wallet.

import { tokenKindsToBalance } from '@midnight-ntwrk/midnight-js-types';

const sponsored = await wallet.balanceUnboundTransaction(
  userTransaction,
  { tokenKindsToBalance: ['dust'] }
);
Enter fullscreen mode Exit fullscreen mode

The resulting transaction uses the sponsor's DUST UTXOs as inputs and routes any change back to the sponsor. The user's transaction content — whatever contract call or token transfer they intended — stays intact.


The ownPublicKey() Gotcha

Here's the thing that trips everyone up. When you call ownPublicKey() on a wallet, you might expect to get the sponsor's public key — the key that corresponds to the DUST UTXOs being used as inputs. That's not what you get.

ownPublicKey() returns the prover's public key. On Midnight, proving and spending are different roles. The prover is the party that generates zero-knowledge proofs. The sponsor is the party that owns the DUST being spent.

In most single-wallet flows, these are the same key. But when you're building a sponsorship service — where the sponsor wallet is a separate service account — you need to be careful about which key you're using for which purpose.

The practical consequence: don't use ownPublicKey() from the user's wallet when you need to route DUST change back to the sponsor. If you mix these up, DUST change ends up in the wrong place or the transaction fails to validate.

// Wrong: this gives you the user's prover key, not the sponsor's key
const wrongKey = await userWallet.ownPublicKey();

// Right: get the sponsor's key from the sponsor wallet
const sponsorKey = await sponsorWallet.ownPublicKey();
Enter fullscreen mode Exit fullscreen mode

When the sponsor builds the DUST input for a transaction, the change output is automatically directed to the sponsor's wallet — the SDK handles this. But if you're manually constructing anything involving public keys, make sure you're sourcing keys from the right wallet instance.


Building a Sponsor Service

Here's a complete TypeScript service that accepts unsigned transactions from users and returns sponsored transactions ready for submission:

import { WalletBuilder } from '@midnight-ntwrk/wallet';
import { NetworkId } from '@midnight-ntwrk/zswap';
import { filter, firstValueFrom } from 'rxjs';
import express from 'express';

const app = express();
app.use(express.json());

async function buildSponsorWallet() {
  const wallet = await WalletBuilder.build(
    process.env.INDEXER_HTTP_URI!,
    process.env.INDEXER_WS_URI!,
    process.env.PROVER_SERVER_URI!,
    process.env.SUBSTRATE_NODE_URI!,
    process.env.SPONSOR_SEED_PHRASE!,   // Separate seed phrase for sponsor
    NetworkId.TestNet,
    'warn',
    true, // discard tx history
  );

  wallet.start();

  await firstValueFrom(
    wallet.state().pipe(
      filter((s) => s.isSynced && (s.dustState?.isStrictlyComplete() ?? true))
    )
  );

  return wallet;
}

let sponsorWallet: Awaited<ReturnType<typeof buildSponsorWallet>>;

app.post('/sponsor', async (req, res) => {
  try {
    const { serializedTransaction } = req.body;

    if (!serializedTransaction || typeof serializedTransaction !== 'string') {
      return res.status(400).json({ error: 'serializedTransaction required' });
    }

    // Deserialize the user's unbalanced transaction
    const userTx = deserializeTransaction(serializedTransaction);

    // Add DUST inputs from the sponsor wallet
    const sponsoredTx = await sponsorWallet.balanceUnboundTransaction(
      userTx,
      { tokenKindsToBalance: ['dust'] }
    );

    // Return the sponsored transaction for the user to submit
    return res.json({
      sponsoredTransaction: serializeTransaction(sponsoredTx),
    });
  } catch (err) {
    console.error('Sponsorship failed:', err);
    return res.status(500).json({ error: 'Failed to sponsor transaction' });
  }
});

async function main() {
  sponsorWallet = await buildSponsorWallet();
  console.log('Sponsor wallet synced and ready');
  app.listen(3001, () => console.log('Sponsor service on :3001'));
}

main().catch(console.error);
Enter fullscreen mode Exit fullscreen mode

The user-facing flow:

  1. User constructs their transaction (contract call, token transfer, whatever).
  2. User serializes it and sends it to POST /sponsor.
  3. Sponsor service calls balanceUnboundTransaction with tokenKindsToBalance: ['dust'].
  4. Sponsor service returns the DUST-funded transaction.
  5. User submits the fully-balanced transaction.

The serialization helpers (deserializeTransaction, serializeTransaction) come from @midnight-ntwrk/ledger. The exact API depends on your SDK version, but the pattern is consistent.


Handling DUST Balances

Before attempting to sponsor a transaction, check whether the sponsor wallet actually has DUST available. Running out of DUST mid-operation produces confusing errors:

import { CoinType } from '@midnight-ntwrk/midnight-js-types';

async function checkDustBalance(wallet: Wallet): Promise<bigint> {
  const balances = await wallet.balances();
  return balances.get(CoinType.DUST) ?? 0n;
}

app.post('/sponsor', async (req, res) => {
  const dustBalance = await checkDustBalance(sponsorWallet);

  if (dustBalance < MINIMUM_DUST_RESERVE) {
    return res.status(503).json({
      error: 'Sponsor wallet low on DUST. Try again later.',
      dustBalance: dustBalance.toString(),
    });
  }

  // proceed with sponsorship...
});

const MINIMUM_DUST_RESERVE = 1_000_000n; // adjust based on typical tx cost
Enter fullscreen mode Exit fullscreen mode

Surface DUST balance in your service's health endpoint so you can alert before it hits zero:

app.get('/health', async (_req, res) => {
  const dustBalance = await checkDustBalance(sponsorWallet);
  const synced = await firstValueFrom(sponsorWallet.state()).then(
    (s) => s.isSynced
  );

  res.json({
    status: synced && dustBalance > MINIMUM_DUST_RESERVE ? 'ok' : 'degraded',
    dustBalance: dustBalance.toString(),
    synced,
  });
});
Enter fullscreen mode Exit fullscreen mode

DUST Regeneration

Every Midnight transaction produces a DUST reward that returns to the sender's wallet after the transaction is confirmed. The reward amount is a fraction of the fee paid.

For a sponsor service, this means your DUST balance declines with each sponsored transaction, but at a slower rate than if there were no regeneration. You don't need to top up after every transaction — but you do need to monitor balance and top up periodically.

The regenerated DUST arrives as a new UTXO in your sponsor wallet after the block containing your transaction is confirmed. The exact timing depends on indexer latency, but it's typically available within a few seconds of confirmation.

If you're running a high-volume sponsor service, model the net DUST drain rate:

// Rough model: net drain = sponsored txs per hour × average fee × (1 - regeneration_rate)
// Regeneration rate is approximately 0.5 on testnet (half the fee comes back)
// Adjust for mainnet once official figures are published

const REGENERATION_RATE = 0.5;
const AVERAGE_FEE_DUST = 500_000n;

function estimatedNetDrainPerHour(txPerHour: number): bigint {
  const grossCost = BigInt(txPerHour) * AVERAGE_FEE_DUST;
  const regenerated = BigInt(Math.floor(Number(grossCost) * REGENERATION_RATE));
  return grossCost - regenerated;
}
Enter fullscreen mode Exit fullscreen mode

Set up an alert when your DUST balance drops below a threshold that gives you enough runway to top up before the service degrades.


Onboarding Users with Zero DUST

The most common use case for sponsorship is onboarding new users who have received NIGHT tokens (from a faucet, an airdrop, or a direct transfer) but have no DUST to pay their first transaction fee.

Here's the complete user journey:

  1. New user generates a wallet seed (client-side, key never leaves their device).
  2. Application receives the user's wallet address.
  3. Application or another user sends NIGHT to the new wallet.
  4. New user wants to do something — shielding, a contract call, a transfer.
  5. User constructs the transaction but can't submit it without DUST.
  6. User calls your sponsor service to get DUST added to their transaction.
  7. User submits the now-funded transaction.

The important thing to get right: the user's private key never leaves their device in this flow. The sponsor service only sees the serialized unbalanced transaction — it adds DUST inputs and returns the modified transaction. The user still signs everything from their side; the sponsor just contributes fee payment.

// Client-side code (user's browser or app)
async function sendWithSponsorship(
  userWallet: Wallet,
  contractCall: UnbalancedTransaction
): Promise<string> {
  // Serialize the unsigned transaction
  const serialized = serializeTransaction(contractCall);

  // Request sponsorship
  const response = await fetch('https://sponsor.example.com/sponsor', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ serializedTransaction: serialized }),
  });

  if (!response.ok) {
    throw new Error('Sponsorship service unavailable');
  }

  const { sponsoredTransaction } = await response.json();

  // Deserialize and submit
  const tx = deserializeTransaction(sponsoredTransaction);
  const txHash = await userWallet.submitTransaction(tx);
  return txHash;
}
Enter fullscreen mode Exit fullscreen mode

For faucet-style use cases, you can automate the entire flow: generate the user's first DUST top-up as a direct DUST transfer rather than sponsoring an existing transaction. A direct DUST transfer to a new wallet gives them enough to pay their own first transaction without needing ongoing sponsorship infrastructure.


Summary

DUST sponsorship on Midnight works by adding DUST inputs to an existing unbalanced transaction using balanceUnboundTransaction with tokenKindsToBalance: ['dust']. The sponsor wallet contributes fee payment; the user's transaction content stays intact.

The main thing to remember: ownPublicKey() returns the prover's key, not the sponsor's key. Keep your wallet references straight and don't mix keys across wallet instances.

Monitor DUST balance carefully. DUST regeneration means your sponsor account won't drain immediately, but it will drain over time. Set a minimum reserve, surface it in your health endpoint, and plan for periodic top-ups.

For new user onboarding, the cleanest path is usually a direct DUST transfer to their wallet rather than ongoing sponsorship. Sponsorship infrastructure is worth building when users return repeatedly and can't be expected to maintain a DUST balance themselves.


Building on Midnight? I also maintain a 200-prompt developer pack for ChatGPT — code review, debugging, documentation, architecture decisions. $19 instant download.

Top comments (0)