Adding Midnight wallets to your app with Dynamic
Midnight is supported in Dynamic, which means you can now put a Midnight wallet in your application without asking the user to install a browser extension first.
Dynamic, a Fireblocks company, is non-custodial wallet infrastructure. One SDK covers EVM, Solana, Bitcoin, Sui, TON, Aleo and Midnight, so a team already using Dynamic for another chain adds Midnight through the same integration rather than a separate one. Keys are managed with TSS-MPC, so the full private key never exists in one place, and Dynamic supports independent vendor recovery, so users retain access to funds even if Dynamic stops operating.
Two routes into Midnight are available. Users can connect an external Midnight wallet such as 1am, which Dynamic talks to through the official @midnight-ntwrk/dapp-connector-api. Or they can sign in with an email address or a social account and get an embedded MPC wallet with no extension involved. Both come from the same package.
The rest of this post covers what makes Midnight different from every other chain Dynamic supports, and what you need to know to build against it.
Three surfaces, one wallet
On most chains a wallet is one address. On Midnight a functional wallet exposes three distinct surfaces, and an integration that treats it as a single address will surface the wrong balance and lose deposits.
Unshielded
What it is: The public address and state. This is the wallet’s main address.
Where Dynamic exposes it: wallet.address
Shielded
What it is: The private address and state, holding a separate token pool.
Where Dynamic exposes it: wallet.additionalAddresses (midnight_shielded)
DUST
What it is: Fee-generation state. DUST pays for Midnight transactions.
Where Dynamic exposes it: wallet.additionalAddresses (midnight_dust)
NIGHT exists as both a shielded and an unshielded asset. These are different token types in different pools rather than one balance displayed twice, and they do not net against each other.
Two consequences follow for anything you build. A deposit screen needs to surface the unshielded and the shielded address, because which one applies depends on the pool the sender pays from. And DUST never appears in a deposit flow at all, because it accrues on chain rather than arriving from somewhere else.
Dynamic derives the shielded and DUST addresses on the client. The server cannot compute them, because deriving them requires JubJub and BLS12–381 key operations, so the client derives and then writes them back through the additional addresses endpoint. An address is stored per network, so one wallet holds a preview address and a mainnet address side by side.
Setting up the connector
Enable Midnight under Chains and Networks in the Dynamic dashboard, then install the connector:
npm i @dynamic-labs/midnightWhich connector you register depends on the wallet type you want to support:
import { DynamicContextProvider, DynamicWidget } from '@dynamic-labs/sdk-react-core';
import { MidnightWalletConnectors } from '@dynamic-labs/midnight';
const App = () => (
<DynamicContextProvider
settings={{
environmentId: 'YOUR_ENVIRONMENT_ID',
walletConnectors: [MidnightWalletConnectors],
}}
>
<DynamicWidget />
</DynamicContextProvider>
);MidnightWalletConnectors handles external wallets. For embedded wallets created from email or social login, register DynamicWaasMidnightConnectors instead.
Embedded Midnight wallets derive their signing keys through the key export mechanism, so Private Key Exports must be enabled under Embedded Wallets > Security in the dashboard. Leave it off and the wallet cannot initialise: balance reads, signing and every other wallet method fail with a 403.
Narrow the wallet before calling any Midnight method. The guard confirms the chain and gives you a typed wallet in one step:
import { isMidnightWallet } from '@dynamic-labs/midnight';
if (!primaryWallet || !isMidnightWallet(primaryWallet)) {
throw new Error('This wallet is not a Midnight wallet');
}Reading addresses and balances
The unshielded address sits on the wallet object. The other two come out of additionalAddresses, keyed by type:
import { WalletAddressType } from '@dynamic-labs/sdk-api-core';
const unshieldedAddress = primaryWallet.address;
const shieldedAddress = primaryWallet.additionalAddresses?.find(
(a) => a.type === WalletAddressType.MidnightShielded,
)?.address;If a dApp needs the shielded coin and encryption public keys, read them from the connector with getShieldedAddresses().
For balances, getFormattedBalances() returns all three surfaces in one call as display-ready strings:
const { unshieldedBalance, shieldedTokenCount, dustBalance, dustSyncing } =
await primaryWallet.getFormattedBalances();Note the shape of the shielded value. shieldedTokenCount is the number of distinct token types with a positive shielded balance, not an amount, which reflects the fact that a shielded pool holds more than NIGHT. When you need per-token amounts, getBalances() returns bigint values keyed by token type across all three surfaces.
The first balance read on a device triggers a one-time background sync, and folding the DUST state takes a while. On embedded wallets, dustSyncing reports whether that is still running, so poll it rather than treating an early zero as the real balance. Later reads resume from a local checkpoint and return quickly.
Registering for DUST
DUST pays for Midnight transactions, and it is generated over time by unshielded NIGHT that has been registered for generation. Fund the wallet with unshielded NIGHT first, since a wallet holding no NIGHT has nothing to register, then call:
const result = await primaryWallet.registerDust();
// result.status -> 'registered' | 'already_registered' | 'already_has_dust' | 'no_
utxos'Registration is a one-off, and it mints nothing. DUST accrues on chain at a rate set by how much NIGHT the wallet holds, so a zero balance immediately afterwards is the expected result rather than a failure. Poll getFormattedBalances() until dustBalance appears. If it stays at zero for a long stretch, the wallet is probably holding too little NIGHT to generate at a visible rate.
Get Midnight’s stories in your inbox
Join Medium for free to get updates from this writer.
Calling registerDust() again on an already-registered wallet returns already_registered with registeredCount: 0. Nothing is left to register, so treat that as a success.
Sending
sendBalance builds, proves and broadcasts in one call, and routes to the correct pool from the recipient address prefix. An mn_shield recipient draws from the shielded pool, and anything else draws from the unshielded pool:
const txHash = await primaryWallet.sendBalance({
toAddress: 'mn_shield...',
amount: '100000000',
});Cross-pool transfers are not supported, so sender and recipient sit in the same pool. To send a token other than NIGHT, pass token.address with the token type identifier and token.decimals if the amount needs conversion.
When you want to inspect or persist the transaction between steps, run the three stages yourself instead:
const { serializedTransaction } = await primaryWallet.createTransferTransaction({
transfers: [{ type: 'unshielded', recipientAddress: 'mn_addr...', amount: '100000000' }],
});
const finalized = await primaryWallet.signTransaction(serializedTransaction);
const { txHash } = await primaryWallet.submitTransaction(finalized);Building or signing a transaction reserves its inputs. If you never submit it, those inputs stay reserved until you release them with revertTransaction(serializedTransaction) for a specific transaction, or revertAllPending() for everything the wallet is holding.
Contract calls split the work
This is where Midnight departs furthest from the pattern Dynamic supports on other chains. A Midnight contract call is a zero-knowledge proof rather than a signed transaction, so there is nothing for a wallet to simply sign. The work divides in two.
Your app runs the circuit, handles witnesses, holds private state and generates the proof, using midnight-js. The wallet handles settlement: adding coin inputs so the transaction balances, paying DUST, signing the unshielded segment and submitting.
getWalletProvider() returns that settlement half as a midnight-js compatible provider, which covers two of the six providers in MidnightProviders:
midnightProvider
Supplied by: Dynamic
Notes: The same object
privateStateProvider
Supplied by: You
Notes: Your contract’s witnesses
publicDataProvider
Supplied by: You
Notes: Indexer, required to read contract state
zkConfigProvider
Supplied by: You
Notes: Your compiled ZK artefacts
proofProvider
Supplied by: You
Notes: A Midnight proof server
Balancing and MPC signing run inside the wallet iframe, so no shielded material, DUST state or MPC key share crosses the boundary. Only public keys and serialised transactions do.
Set the target network with setNetworkId() before you build providers, since the wallet derives a separate address per network.
Things that will catch you out
Five behaviours in this integration fail with errors that point somewhere other than the cause.
Your zkConfigProvider needs to throw. A circuit that creates a shielded coin needs prover keys for Midnight’s own zswap circuits, which are not in your compiled artefacts. The proof server holds them. Your provider should throw for circuit IDs it does not own, which tells midnight-js to omit proving data so the server uses its own keys. Return something for those IDs and it lands in the preimage, and the proof server rejects the request with a bare 400.
Check the content type on artefact requests. A dev server’s single-page-app fallback returns 200 with index.html for an unknown path, so a wrong artefact path becomes HTML inside your proof preimage. The symptom is the same bare 400. Reject HTML in a custom fetch and a bad path fails visibly instead.
Deploying needs no proof, but calling does. A deploy publishes state and verifier keys with no circuit execution to prove, so it succeeds even when the proof server is unreachable. The first callTx is where proofProvider is actually used, which is why a misconfigured proof server presents as “deploy worked, calls broke”.
The first call is slow. getWalletProvider() initialises the wallet inside the iframe, including a full sync on a cold cache. Expect well over a minute with no intermediate progress. Later calls reuse the synced wallet.
submitTx returns a submission identifier. That is the value you poll for inclusion, and it differs from the canonical transaction hash a block explorer indexes. Query an indexer with the wrong one and you get an empty result rather than an error, which reads like a failed transaction.
One more, which is a design property rather than a pitfall: witnesses live in your app’s private state and never reach the wallet. If you lose them, gated circuits on that contract become uncallable, and the wallet cannot help you recover them because it never held them. Persist anything you cannot regenerate.
Where to go next
If you are already running Dynamic for another chain, enabling Midnight is a dashboard toggle and one package. If you are starting from Midnight, the embedded wallet path is the shortest route to getting a user from an email address to a shielded transaction without an extension in the way.









