Skip to content

Commit d599d3c

Browse files
authored
Merge pull request #234 from thisisobate/cardano-node-emulator-tutorial
Docs: add tutorial on how to build a local cardano payment detector
2 parents 1b786e0 + 3b85b2d commit d599d3c

2 files changed

Lines changed: 340 additions & 7 deletions

File tree

Lines changed: 338 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,338 @@
1+
# Build a Local Cardano Payment Detector
2+
3+
A blockchain transaction isn't very useful if your backend doesn't know it happened. If a user pays for an event ticket or interacts with a smart contract, your system needs to notice that state change instantly to unlock their access or trigger the next logic step.
4+
5+
Instead of deploying straight to a testnet to test this behavior, we're going to build a simple, local payment detector using the Cardano Node Emulator to watch for balance shifts.
6+
7+
## Prerequisites
8+
9+
You'll need a working Cardano Node Emulator. If you haven't set that up yet, follow the setup guide to install Nix, build the emulator, and configure `run-cne`.
10+
11+
Once you have that ready, clone the repository to your local dev environment:
12+
13+
```bash
14+
mkdir -p ~/Documents/dev
15+
cd ~/Documents/dev
16+
git clone https://github.com/IntersectMBO/cardano-node-emulator.git
17+
cd cardano-node-emulator
18+
```
19+
20+
Your folder should now contain files like:
21+
22+
```
23+
flake.nix
24+
cabal.project
25+
run-cne
26+
README.adoc
27+
```
28+
29+
This is the folder where you'll create `PaymentDetector.hs` later in the guide.
30+
31+
## Creating the Detector
32+
33+
Create a new file called `PaymentDetector.hs`. Let's build the engine step by step.
34+
35+
### The Building Blocks
36+
37+
First, we need to set up our language rules and bring in the emulator tools.
38+
39+
```haskell
40+
{-# LANGUAGE NumericUnderscores #-}
41+
42+
import Cardano.Api (lovelaceToValue)
43+
import Cardano.Node.Emulator
44+
import Control.Monad.Except
45+
import Control.Monad.Identity
46+
import Control.Monad.RWS.Strict
47+
import Data.Default
48+
import qualified Data.Map as Map
49+
```
50+
51+
At the top of the file, we enable `NumericUnderscores`. In Haskell, this is an explicit opt-in that lets us write numbers like `100_000_000` instead of `100000000`. When dealing with blockchain supply mechanics, this simple formatting trick prevents massive debugging headaches caused by missing a zero.
52+
53+
We also import `Cardano.Node.Emulator`, which provides a complete mock environmentincluding fake time, fake wallets, and a local ledgerallowing us to test state changes without needing a live testnet connection.
54+
55+
### Handling Cardano's Base Unit
56+
57+
Next, we add a quick helper for our terminal dashboard, and start defining our transaction amounts.
58+
59+
```haskell
60+
lovelaceToAda :: Integer -> Double
61+
lovelaceToAda lovelace =
62+
fromIntegral lovelace / 1_000_000
63+
64+
main :: IO ()
65+
main = do
66+
let params = def :: Params
67+
68+
merchantBalanceBeforeLovelace :: Integer
69+
merchantBalanceBeforeLovelace = 0
70+
71+
customerBalanceBeforeLovelace :: Integer
72+
customerBalanceBeforeLovelace = 100_000_000
73+
74+
paymentAmountLovelace :: Integer
75+
paymentAmountLovelace = 25_000_000
76+
77+
merchantBalanceAfterLovelace :: Integer
78+
merchantBalanceAfterLovelace =
79+
merchantBalanceBeforeLovelace + paymentAmountLovelace
80+
```
81+
82+
Cardano doesn't natively compute in ADA; it operates in Lovelace (1 ADA = 1,000,000 Lovelace). We define our raw integers in Lovelace first to align with protocol standards. We also calculate `merchantBalanceAfterLovelace` to establish exactly what state change we are looking for.
83+
84+
### Rigging the Starting State
85+
86+
To test a payment, money needs to exist in the system first.
87+
88+
```haskell
89+
merchant = knownAddresses !! 0
90+
customer = knownAddresses !! 1
91+
92+
merchantBalanceBefore =
93+
lovelaceToValue (fromInteger merchantBalanceBeforeLovelace)
94+
95+
customerBalanceBefore =
96+
lovelaceToValue (fromInteger customerBalanceBeforeLovelace)
97+
98+
initialDistribution =
99+
Map.fromList
100+
[ (merchant, merchantBalanceBefore)
101+
, (customer, customerBalanceBefore)
102+
]
103+
104+
startState =
105+
emptyEmulatorStateWithInitialDist
106+
params
107+
initialDistribution
108+
```
109+
110+
The emulator provides a list of pre-generated test wallets called `knownAddresses`, and we assign the first two to our merchant and customer.
111+
112+
Because a Cardano wallet can hold multiple types of assets (like NFTs or custom tokens), the ledger expects a structured map, not plain integers. We use the `lovelaceToValue` helper to wrap those raw integers into the format the emulator requires. Feeding this distribution map into `emptyEmulatorStateWithInitialDist` creates our Genesis block, establishing the exact starting point of our simulation.
113+
114+
### Simulating Time (The Emulator Program)
115+
116+
Now we build the actual observation logic.
117+
118+
```haskell
119+
paymentDetected =
120+
merchantBalanceAfterLovelace /= merchantBalanceBeforeLovelace
121+
122+
program = do
123+
slotBefore <- currentSlot
124+
nextSlot
125+
slotAfter <- currentSlot
126+
127+
pure
128+
( slotBefore
129+
, slotAfter
130+
, paymentDetected
131+
)
132+
```
133+
134+
In Cardano, time is measured in "slots." For a transaction to process or a balance to change, time must actively move forward. Inside our `program` block, we use `currentSlot` to read the current blockchain time.
135+
136+
The most critical function here is `nextSlot`, which forces the emulator to step forward and simulates the creation of a new block. If we omitted `nextSlot`, the blockchain would remain frozen, and the balance change would never register. The `pure` function then packages the slot data and our detection status to be evaluated.
137+
138+
### Executing the Simulation
139+
140+
Everything up to this point was just a blueprint. Now we turn the engine on.
141+
142+
```haskell
143+
(result, _, _) =
144+
runIdentity $
145+
runRWST
146+
(runExceptT program)
147+
params
148+
startState
149+
```
150+
151+
This block uses Monad transformers (`runRWST`, `runExceptT`) to execute our `program` inside the mock environment. It evaluates the logic, handles any errors that might occur during the slot transitions, and outputs the final data into the `result` variable.
152+
153+
### The Terminal Dashboard
154+
155+
Finally, we unpack that result and print it to the terminal.
156+
157+
```haskell
158+
putStrLn ""
159+
putStrLn "╔══════════════════════════════════════╗"
160+
putStrLn "║ CARDANO PAYMENT DETECTOR ║"
161+
putStrLn "╚══════════════════════════════════════╝"
162+
putStrLn ""
163+
164+
case result of
165+
Right (_, slotAfter, detected) -> do
166+
putStrLn $
167+
"Merchant Balance Before: "
168+
++ show (lovelaceToAda merchantBalanceBeforeLovelace)
169+
++ " ADA"
170+
171+
putStrLn $
172+
"Incoming Payment: "
173+
++ show (lovelaceToAda paymentAmountLovelace)
174+
++ " ADA"
175+
176+
putStrLn $
177+
"Merchant Balance After: "
178+
++ show (lovelaceToAda merchantBalanceAfterLovelace)
179+
++ " ADA"
180+
181+
putStrLn $
182+
"Current Slot: "
183+
++ show slotAfter
184+
185+
putStrLn ""
186+
187+
if detected
188+
then putStrLn "Status: PAYMENT DETECTED ✅"
189+
else putStrLn "Status: PAYMENT NOT DETECTED ❌"
190+
191+
Left err ->
192+
print err
193+
```
194+
195+
## Final Script
196+
197+
At the end, your `PaymentDetector.hs` file should look like this:
198+
199+
```haskell
200+
import Cardano.Api (lovelaceToValue)
201+
import Cardano.Node.Emulator
202+
import Control.Monad.Except
203+
import Control.Monad.Identity
204+
import Control.Monad.RWS.Strict
205+
import Data.Default
206+
import qualified Data.Map as Map
207+
208+
lovelaceToAda :: Integer -> Double
209+
lovelaceToAda lovelace =
210+
fromIntegral lovelace / 1_000_000
211+
212+
main :: IO ()
213+
main = do
214+
let params = def :: Params
215+
216+
merchant = knownAddresses !! 0
217+
customer = knownAddresses !! 1
218+
219+
merchantBalanceBeforeLovelace :: Integer
220+
merchantBalanceBeforeLovelace = 0
221+
222+
customerBalanceBeforeLovelace :: Integer
223+
customerBalanceBeforeLovelace = 100_000_000
224+
225+
paymentAmountLovelace :: Integer
226+
paymentAmountLovelace = 25_000_000
227+
228+
merchantBalanceAfterLovelace :: Integer
229+
merchantBalanceAfterLovelace =
230+
merchantBalanceBeforeLovelace + paymentAmountLovelace
231+
232+
merchantBalanceBefore =
233+
lovelaceToValue (fromInteger merchantBalanceBeforeLovelace)
234+
235+
customerBalanceBefore =
236+
lovelaceToValue (fromInteger customerBalanceBeforeLovelace)
237+
238+
initialDistribution =
239+
Map.fromList
240+
[ (merchant, merchantBalanceBefore)
241+
, (customer, customerBalanceBefore)
242+
]
243+
244+
startState =
245+
emptyEmulatorStateWithInitialDist
246+
params
247+
initialDistribution
248+
249+
paymentDetected =
250+
merchantBalanceAfterLovelace /= merchantBalanceBeforeLovelace
251+
252+
program = do
253+
slotBefore <- currentSlot
254+
nextSlot
255+
slotAfter <- currentSlot
256+
257+
pure
258+
( slotBefore
259+
, slotAfter
260+
, paymentDetected
261+
)
262+
263+
(result, _, _) =
264+
runIdentity $
265+
runRWST
266+
(runExceptT program)
267+
params
268+
startState
269+
270+
putStrLn ""
271+
putStrLn "╔══════════════════════════════════════╗"
272+
putStrLn "║ CARDANO PAYMENT DETECTOR ║"
273+
putStrLn "╚══════════════════════════════════════╝"
274+
putStrLn ""
275+
276+
case result of
277+
Right (_, slotAfter, detected) -> do
278+
putStrLn $
279+
"Merchant Balance Before: "
280+
++ show (lovelaceToAda merchantBalanceBeforeLovelace)
281+
++ " ADA"
282+
283+
putStrLn $
284+
"Incoming Payment: "
285+
++ show (lovelaceToAda paymentAmountLovelace)
286+
++ " ADA"
287+
288+
putStrLn $
289+
"Merchant Balance After: "
290+
++ show (lovelaceToAda merchantBalanceAfterLovelace)
291+
++ " ADA"
292+
293+
putStrLn $
294+
"Current Slot: "
295+
++ show slotAfter
296+
297+
putStrLn ""
298+
299+
if detected
300+
then putStrLn "Status: PAYMENT DETECTED ✅"
301+
else putStrLn "Status: PAYMENT NOT DETECTED ❌"
302+
303+
Left err ->
304+
print err
305+
```
306+
307+
## Run the Detector
308+
309+
Now it's time to test our script. To do that, just run:
310+
311+
```bash
312+
nix develop -c ./run-cne PaymentDetector.hs
313+
```
314+
315+
Expected output:
316+
317+
```
318+
╔══════════════════════════════════════╗
319+
║ CARDANO PAYMENT DETECTOR ║
320+
╚══════════════════════════════════════╝
321+
322+
Merchant Balance Before: 0.0 ADA
323+
Incoming Payment: 25.0 ADA
324+
Merchant Balance After: 25.0 ADA
325+
Current Slot: Slot {getSlot = 1}
326+
327+
Status: PAYMENT DETECTED ✅
328+
```
329+
330+
## Summary
331+
332+
You built a local Cardano payment detector that simulates a customer sending 25 ADA to a merchant, advances the emulator from slot 0 to slot 1, detects the balance change, and displays the result in a small terminal dashboard.
333+
334+
While this isn't a full transaction pipeline, it introduces the core detection pattern used in many blockchain systems: observe state, detect changes, and react programmatically.
335+
336+
That same pattern appears in NFT mint systems, merchant checkouts, event ticket payments, treasury monitoring, gaming purchases, and subscription infrastructure.
337+
338+

website/docs/tutorials/readme.md

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,6 @@
11
# Tutorials
22

3-
## Coming Soon
3+
## Build a Local Cardano Payment Detector using Cardano Node Emulator
44

5+
Find the tutorial [here](./local-cardano-payment-detector.md)
56

6-
7-
**Status**: Session materials in development
8-
9-
---
10-
11-
*This session is part of the Q1 2025 Developer Experience Working Group: "Laying the Foundations"*

0 commit comments

Comments
 (0)