Building an Unshielded Token dApp on Midnight with React and Compact
Architecting Transparent Assets on Midnight: A Compact and React Integration Guide
Current Situation Analysis
Developers entering the Midnight ecosystem often encounter a steep learning curve due to the platform's emphasis on privacy-preserving computation. The default assumption is that all smart contracts must utilize shielded state and zero-knowledge proofs. This creates a significant barrier to entry, as teams must master complex cryptographic primitives before validating basic business logic.
This misconception leads to two common failures:
- Premature Optimization for Privacy: Teams invest weeks in shielded circuit design for use cases that do not require confidentiality, resulting in bloated development cycles and unnecessary compute overhead.
- Debugging Paralysis: Shielded contracts obscure state changes, making it difficult to trace errors during the prototyping phase. Developers cannot inspect balances or transfer flows using standard logging tools.
Midnight addresses this through unshielded tokens. Unshielded assets operate with transparent state, exposing balances and transfer logic on the ledger. This does not compromise the underlying security model; it simply opts out of encryption for specific data fields. This approach enables standard debugging workflows, rapid iteration, and immediate visibility into contract state. It is the strategic choice for MVPs, compliance-driven applications requiring auditability, and educational onboarding where state transparency accelerates understanding.
WOW Moment: Key Findings
The decision between unshielded and shielded implementations is not binary but contextual. The following comparison highlights the operational trade-offs based on Midnight's architecture.
| Feature | Unshielded Token Implementation | Shielded Token Implementation |
|---|---|---|
| State Visibility | Public balances and transfer amounts | Encrypted state; zero-knowledge proofs |
| Debugging Complexity | Low. Standard logs and state inspection | High. Requires circuit inspection tools |
| Development Velocity | Fast. Iterative feedback loops | Slower. Constraints on circuit logic |
| Compute Overhead | Minimal. Standard execution | Higher. ZK proof generation/verification |
| Ideal Use Case | MVPs, Compliance, Learning, Public Ledgers | High-Privacy Finance, Identity, Sensitive Data |
Why this matters: Unshielded tokens allow teams to decouple business logic validation from privacy engineering. You can ship a functional token economy on Midnight in days rather than weeks, then migrate specific flows to shielded contracts only where privacy is a hard requirement. This reduces time-to-market and lowers the risk of cryptographic implementation errors in non-critical paths.
Core Solution
This section outlines the architecture for a transparent token dApp using Compact for contract logic and React for the frontend interface. The implementation focuses on type safety, state management, and a decoupled API bridge.
1. Contract Design with Compact
Compact is Midnight's domain-specific language for smart contracts. For unshielded tokens, the contract manages public state variables. The design below implements a transparent asset with issuance and transfer capabilities.
Architecture Decision: We use UInt64 for balances to align with Midnight's native numeric types. The Map structure provides efficient lookups for address balances. Error handling uses require statements to fail transactions early, conserving gas and providing clear rejection reasons.
// contracts/TransparentAsset.compact
contract TransparentAsset {
state {
total_supply: UInt64 = 0;
balances: Map[Address, UInt64] = Map.empty;
}
// Issues new tokens to a recipient.
// Only callable by authorized logic in production; simplified here for demonstration.
function issue(recipient: Address, amount: UInt64) {
require(amount > 0, "Issue amount must be positive");
state.total_supply += amount;
// Safe addition with fallback for new addresses
let current_balance = state.balances[recipient] ?? 0;
state.balances[recipient] = current_balance + amount;
}
// Transfers tokens between addresses.
function transfer(sender: Address, receiver: Address, amount: UInt64) {
require(amount > 0, "Transfer amount must be positive");
require(state.balances[sender] >= amount, "Insufficient sender balance");
state.balances[sender] -= amount;
let receiver_balance = state.balances[receiver] ?? 0;
state.balances[receiver] = receiver_balance + amount;
}
}
2. Compilation and Artifact Generation
Compact code must be compiled to generate artifacts that the Midnight node and frontend can consume. The compilation process produces JSON representations of the contract interface, types, and circuit constraints.
Command:
compact compile contracts/TransparentAsset.compact contracts/managed/transparent-asset
Rationale: The managed/ directory contains the output artifacts. These files are consumed by the API bridge to serialize inputs and deserialize outputs. Never commit compiled artifacts to version control if they are generated dynamically; however, for stable releases, pinning artifacts ensures reproducibility.
3. API Bridge Implementation
Direct frontend interaction with the Midnight node is discouraged due to serialization complexity and security concerns. An API bridge handles the translation between HTTP requests and Compact function calls.
Architecture Decision: The bridge runs as a separate service. It loads the compiled artifacts, validates incoming payloads against Compact types, and submits transactions to the ledger. This decouples the UI from ledger specifics and allows for independent scaling of the API layer.
// scripts/ledger-proxy.mjs
import express from 'express';
import { loadCompactArtifacts } from '@midnight-ir/compact-runtime';
import { cr
eateLedgerClient } from './ledger-client.mjs';
const app = express(); app.use(express.json());
// Load artifacts generated by the compiler const artifacts = loadCompactArtifacts('./contracts/managed/transparent-asset'); const ledger = createLedgerClient(process.env.MIDNIGHT_NODE_URL);
app.post('/api/issue', async (req, res) => { try { const { recipient, amount } = req.body; // Validate types against Compact schema const validated = artifacts.schemas.issue.validate({ recipient, amount });
const tx = await ledger.submitTransaction('issue', validated);
res.json({ status: 'submitted', txHash: tx.hash });
} catch (err) {
res.status(400).json({ error: err.message });
}
});
app.get('/api/state/:address', async (req, res) => { const { address } = req.params; const state = await ledger.queryState('balances', address); res.json({ balance: state ?? 0n }); });
app.listen(4000, () => console.log('Ledger proxy running on port 4000'));
#### 4. Frontend Integration with React
The React application manages wallet state, user inputs, and balance displays. It communicates with the API bridge via fetch calls.
**Architecture Decision:** We use custom hooks to encapsulate wallet logic and data fetching. This promotes reusability and keeps components clean. `BigInt` is used for all numeric values to prevent precision loss, as JavaScript's `Number` type cannot safely represent large integers common in blockchain contexts.
```typescript
// src/components/AssetManager.tsx
import { useState, useEffect, useCallback } from 'react';
import { useLaceWallet } from '../hooks/useLaceWallet';
import { fetchBalance, submitIssue } from '../services/api';
export const AssetManager = () => {
const { address, isConnected, connect } = useLaceWallet();
const [balance, setBalance] = useState<BigInt>(0n);
const [inputAmount, setInputAmount] = useState<string>('');
const [isProcessing, setIsProcessing] = useState(false);
const refreshBalance = useCallback(async () => {
if (!address) return;
const data = await fetchBalance(address);
setBalance(BigInt(data.balance));
}, [address]);
useEffect(() => {
refreshBalance();
}, [refreshBalance]);
const handleIssue = async () => {
if (!address || !inputAmount) return;
setIsProcessing(true);
try {
await submitIssue({
recipient: address,
amount: BigInt(inputAmount)
});
await refreshBalance();
setInputAmount('');
} catch (err) {
console.error('Issue failed:', err);
} finally {
setIsProcessing(false);
}
};
if (!isConnected) {
return <button onClick={connect}>Connect Lace Wallet</button>;
}
return (
<div className="asset-manager">
<h2>Transparent Asset Manager</h2>
<p>Address: {address}</p>
<p>Balance: {balance.toString()}</p>
<div className="controls">
<input
type="text"
value={inputAmount}
onChange={(e) => setInputAmount(e.target.value)}
placeholder="Amount"
/>
<button
onClick={handleIssue}
disabled={isProcessing || !inputAmount}
>
{isProcessing ? 'Processing...' : 'Issue Tokens'}
</button>
</div>
</div>
);
};
Pitfall Guide
Production experience with Midnight reveals specific failure modes. Avoid these common mistakes to ensure stability and correctness.
-
JavaScript Number Precision Loss
- Explanation: Using
Numberfor token amounts causes silent data corruption for values exceedingNumber.MAX_SAFE_INTEGER. - Fix: Enforce
BigIntfor all balance and amount variables. Convert user input strings toBigIntexplicitly before transmission.
- Explanation: Using
-
Ignoring Compact Compilation Warnings
- Explanation: Compact may produce warnings about unused variables or type mismatches that do not halt compilation but cause runtime failures.
- Fix: Treat warnings as errors. Run
npm run doctoror equivalent validation scripts after every contract change. Inspect themanaged/output for completeness.
-
State Drift After Transactions
- Explanation: The frontend may display stale balances because the ledger state update is asynchronous and the UI does not poll or listen for events.
- Fix: Implement a refresh mechanism after transaction submission. For production, use WebSocket subscriptions or polling intervals to sync state.
-
Hardcoding Contract Addresses
- Explanation: Embedding contract addresses in frontend code breaks when deploying to different networks or upgrading contracts.
- Fix: Inject addresses via environment variables or a configuration service. Support dynamic address resolution based on the connected network.
-
Skipping Artifact Setup
- Explanation: Cloning a repository without running the setup script results in missing artifacts, causing the API bridge to crash on startup.
- Fix: Always run the setup command (e.g.,
npm run setup) after cloning. Verify artifact existence before starting services.
-
Confusing Unshielded and Shielded Models
- Explanation: Attempting to hide data in an unshielded contract leads to confusion, as the ledger will still expose the state.
- Fix: Clearly document the privacy model. If data must be private, switch to a shielded contract design. Do not mix models within the same contract without explicit separation.
-
API Rate Limiting and Throttling
- Explanation: Rapid UI interactions can flood the API bridge, causing transaction nonce conflicts or node overload.
- Fix: Implement debouncing on input fields. Add queue management in the API bridge to serialize transaction submissions per address.
Production Bundle
Action Checklist
- Environment Verification: Confirm Node.js 20+ and Midnight Lace Wallet are installed. Run
npm run doctorto validate tooling. - Artifact Generation: Compile the Compact contract and verify artifacts exist in the
managed/directory. - BigInt Enforcement: Audit all frontend code to ensure
BigIntis used for numeric values. RemoveNumberconversions. - Error Boundaries: Wrap React components in error boundaries to handle wallet rejections and API failures gracefully.
- Multi-Account Testing: Test transfer flows using multiple Lace Wallet accounts to verify state updates across different addresses.
- API Security: Restrict API endpoints to authorized origins. Validate all inputs against Compact schemas before processing.
- State Sync Strategy: Implement a reliable method for refreshing balances after transactions, such as polling or event listeners.
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|---|---|---|
| Rapid Prototyping | Unshielded + Compact | Fast iteration with visible state reduces debugging time. | Low Dev Cost |
| Regulatory Audit | Unshielded | Transparent ledger allows auditors to verify flows directly. | Compliance Savings |
| High-Security Finance | Shielded | Privacy is mandatory; unshielded exposes sensitive data. | Higher Dev/Compute Cost |
| Educational Onboarding | Unshielded | Transparency helps developers understand state transitions. | Training Efficiency |
Configuration Template
Use this template to standardize project scripts and dependencies.
{
"name": "midnight-transparent-asset",
"version": "1.0.0",
"scripts": {
"setup": "npm install && npm run compile:contract",
"compile:contract": "compact compile contracts/TransparentAsset.compact contracts/managed/transparent-asset",
"dev:api": "node scripts/ledger-proxy.mjs",
"dev:ui": "vite",
"dev": "concurrently \"npm run dev:api\" \"npm run dev:ui\"",
"doctor": "node scripts/doctor.mjs",
"check:artifacts": "node scripts/check-artifacts.mjs"
},
"dependencies": {
"@midnight-ir/compact-runtime": "^latest",
"express": "^4.18.2",
"react": "^18.2.0",
"react-dom": "^18.2.0"
},
"devDependencies": {
"concurrently": "^8.2.0",
"typescript": "^5.0.0",
"vite": "^5.0.0"
}
}
Quick Start Guide
- Initialize Project: Clone the repository and run
npm run setupto install dependencies and compile the contract. - Validate Environment: Execute
npm run doctorto ensure all tools are configured correctly. - Start Services: Run
npm run devto launch the API bridge and React frontend concurrently. - Connect Wallet: Open the UI in a browser, click "Connect Lace Wallet," and authorize the connection.
- Test Flow: Enter an amount and click "Issue Tokens." Verify the balance updates and the transaction appears in the Lace Wallet history.
