Module 5 — Firmware, Business Logic & Stellar Blockchain Stack
| Field | Value |
|---|---|
| Document ID | MR-ENG-005 |
| Revision | A |
| Date | 2026-09-13 |
| Owner | Principal Embedded Systems Engineer / Blockchain Solutions Architect |
| Status | Released for review |
5.1 Scope
This module specifies the host software architecture, bill-validator driver, tamper-response firmware interface, deposit/fee ledger arithmetic, and Stellar settlement design for the Money Roll terminal. It references and explains four companion source files:
| File | Purpose |
|---|---|
docs/engineering/firmware/validator_daemon.py | moneyroll-hwd ID-003 bill validator driver |
docs/engineering/firmware/fee_ledger.py | Deposit/fee arithmetic used inside moneyroll-ledger |
docs/engineering/stellar/settle.ts | moneyroll-stellar settlement worker |
docs/engineering/stellar/package.json | Node package manifest for the settlement worker |
5.2 Host software architecture
5.2.1 Platform
| Item | Value |
|---|---|
| SBC | Raspberry Pi Compute Module 4 (CM4104032, 4 GB RAM, 32 GB eMMC, Wi-Fi variant disabled) |
| Carrier board | Custom Money Roll I/O carrier (rev C) with isolated RS-232/RS-485/UART headers |
| OS | Debian 12 (bookworm), 64-bit, linux-image-arm64 |
| Init system | systemd 252 |
| Kiosk display | 10.1" capacitive touch panel, HDMI + USB-HID |
| Runtime | Python 3.11 (hwd, ledger), Node.js 20 LTS (stellar worker), Chromium 124 (kiosk UI) |
5.2.2 systemd services
| Unit | Language | Responsibility | Restart policy |
|---|---|---|---|
moneyroll-hwd.service | Python 3.11, asyncio | Owns all serial/GPIO hardware: bill validator, tamper MCU UART, cassette lock solenoid, card dispenser | Restart=always, RestartSec=2 |
moneyroll-ledger.service | Python 3.11, asyncio | Session state machine, fee computation, guard enforcement, hands off completed sessions to the settlement worker | Restart=always, RestartSec=2 |
moneyroll-stellar.service | Node 20 | Consumes settlement requests, builds/signs/submits Stellar transactions, maintains idempotency journal | Restart=on-failure, RestartSec=5, StartLimitBurst=10 |
moneyroll-ui.service | Chromium (kiosk flags) | Renders the customer-facing UI from http://127.0.0.1:8080/ | Restart=always, depends on moneyroll-ledger.service via Requires=/After= |
Example unit file (/etc/systemd/system/moneyroll-hwd.service):
[Unit]
Description=Money Roll hardware daemon (ID-003 validator, tamper MCU, dispenser)
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=moneyroll-hwd
Group=moneyroll-hwd
ExecStart=/opt/moneyroll/venv/bin/python3 /opt/moneyroll/firmware/validator_daemon.py \
--port /dev/ttyAMA2 --pub tcp://127.0.0.1:5556 --rep tcp://127.0.0.1:5557
Restart=always
RestartSec=2
NoNewPrivileges=true
ProtectSystem=strict
ReadWritePaths=/var/lib/moneyroll /dev/ttyAMA2
DeviceAllow=/dev/ttyAMA2 rw
AmbientCapabilities=
CapabilityBoundingSet=
[Install]
WantedBy=multi-user.target
Hardening details (ProtectSystem, seccomp filters, device cgroup rules) are cross-referenced
in Module 6 (OS Hardening & Physical Security).
5.2.3 IPC transport
All inter-service IPC runs over ZeroMQ on loopback only (no network exposure). Two patterns are used:
| Pattern | Endpoint(s) | Direction | Use |
|---|---|---|---|
| PUB/SUB | tcp://127.0.0.1:5556 (hwd → ledger), tcp://127.0.0.1:5558 (ledger → stellar) | Fire-and-forget event stream | Bill events, tamper events, session-completed events |
| REQ/REP | tcp://127.0.0.1:5557 (ledger ↔ hwd), tcp://127.0.0.1:5559 (ui ↔ ledger) | Synchronous command/response | Inhibit control, get_state, quote requests |
All payloads are UTF-8 JSON. PUB topics are the first frame of a multipart message (ZeroMQ topic-based filtering); the JSON payload is the second frame.
5.2.4 Message schemas
hwd → ledger, topic bill.stacked:
| Field | Type | Description |
|---|---|---|
type | string | "bill.stacked" |
denomination | integer | USD face value, one of 1/2/5/10/20/50/100 |
serial | string | null | Bill serial number if OCR is fitted (not standard hardware) |
ts | number | Unix epoch seconds, float |
hwd → ledger, topic bill.returned:
| Field | Type | Description |
|---|---|---|
type | string | "bill.returned" |
denomination | integer | null | Unknown if rejected before recognition |
serial | string | null | |
ts | number |
hwd → ledger, topic validator.error:
| Field | Type | Description |
|---|---|---|
type | string | "validator.error" |
code | string | One of the ID-003 error group names (Section 5.3.2) |
ts | number |
hwd → ledger, topic tamper.event:
| Field | Type | Description |
|---|---|---|
type | string | "tamper.event" |
event | string | ARMED | DISARMED | BREACH_DETECTED | DYE_FIRED | MCU_HEARTBEAT_LOST |
zone | string | Physical zone id, e.g. CASSETTE_DOOR, HEAD_ENCLOSURE |
ts | number |
ledger → stellar, topic session.completed:
| Field | Type | Description |
|---|---|---|
type | string | "session.completed" |
session_id | string | UUID v4 |
destination | string | G-address, M-address, or card metadata JSON string |
net_usd | string | Decimal string, 2 dp |
net_xlm | string | Decimal string, 7 dp |
net_usdc | string | null | Decimal string if USDC path selected |
card_issued | boolean | |
ts | number |
ui ↔ ledger REQ/REP, request quote:
| Field | Type | Description |
|---|---|---|
cmd | string | "quote" |
session_id | string | |
card_requested | boolean |
Response:
| Field | Type | Description |
|---|---|---|
ok | boolean | |
gross_usd | string | |
service_fee_usd | string | |
card_fee_usd | string | |
net_usd | string | |
net_xlm | string | |
guard | string | null | null if settlement is permitted, else a GuardFailure value |
ledger ↔ hwd REQ/REP, request inhibit:
| Field | Type | Description |
|---|---|---|
cmd | string | "inhibit" |
value | boolean | true disables further note acceptance for the session |
Response: {"ok": true}.
5.3 Bill validator protocol (ID-003)
5.3.1 Frame format
ID-003 (the protocol implemented by JCM/ICT genesis-class validators, e.g. JCM iVIZION, ICT L70/L72) uses fixed byte-oriented framing over UART, 9600 baud, 8 data bits, even parity, 1 stop bit (9600 8E1):
+------+------+------+------------------+---------+
| SYNC | LNG | CMD | DATA | CRC16 |
| 0xFC | 1B | 1B | 0..N bytes | 2B |
+------+------+------+------------------+---------+
SYNC— always0xFC.LNG— total frame length in bytes, including SYNC, LNG, CMD, DATA and the 2 CRC bytes.CMD— command byte on host→validator frames, status byte on validator→host frames.DATA— variable-length payload, e.g. the escrowed bill-type byte.CRC— CRC-16/CCITT (polynomial0x1021, initial value0x0000, no input/output reflection, no final XOR), computed over every byte fromSYNCthrough the end ofDATA, transmitted little-endian (low byte first). Seecrc16_ccitt()invalidator_daemon.py.
5.3.2 Status codes
| Code | Name | Group |
|---|---|---|
| 0x11 | ENABLE / IDLE | Normal |
| 0x12 | ACCEPTING | Normal |
| 0x13 | ESCROW | Normal |
| 0x14 | STACKING | Normal |
| 0x15 | VEND VALID | Normal |
| 0x16 | STACKED | Normal |
| 0x17 | REJECTING | Normal |
| 0x18 | RETURNING | Normal |
| 0x19 | HOLDING | Normal |
| 0x1A | DISABLE / INHIBIT | Normal |
| 0x1B | INITIALIZE | Normal |
| 0x40 | POWER UP | Power-up group |
| 0x41 | POWER UP WITH BILL IN ACCEPTOR | Power-up group |
| 0x42 | POWER UP WITH BILL IN STACKER | Power-up group |
| 0x43 | STACKER FULL | Error group |
| 0x44 | STACKER OPEN | Error group |
| 0x45 | ACCEPTOR JAM | Error group |
| 0x46 | STACKER JAM | Error group |
| 0x47 | PAUSE | Error group |
| 0x48 | CHEATED | Error group |
| 0x49 | FAILURE | Error group |
| 0x4A | COMMUNICATION ERROR | Error group |
| 0x4B | INVALID COMMAND | Error group |
Note: STACK_1 (0x41) and POWER_UP_WITH_BILL_IN_ACCEPTOR (0x41) share the numeric value
0x41 by design of the underlying spec — the value is interpreted as a command byte when sent
host→validator and as a status byte when sent validator→host; the direction of the frame
(request vs. response) disambiguates it. The Python implementation keeps these as separate
IntEnum types (ID003Command vs. ID003Status) to avoid ambiguity in code.
5.3.3 USD denomination map
| ID-003 bill-type byte | USD value |
|---|---|
| 0x00 | $1 |
| 0x01 | $2 |
| 0x02 | $5 |
| 0x03 | $10 |
| 0x04 | $20 |
| 0x05 | $50 |
| 0x06 | $100 |
This mapping is programmed into the validator's non-volatile configuration at deployment time
via the manufacturer's configuration tool and must match DENOMINATION_MAP_USD in
validator_daemon.py. A mismatch here is a critical defect (would misattribute deposit value).
5.3.4 Escrow flow
host validator
|--- STATUS_REQUEST (0x11) -------->|
|<-- ESCROW (0x13, data=bill_type) --|
| [decision hook: session inhibited?]
|--- STACK_1 (0x41) or RETURN (0x43)->|
|<-- STACKING (0x14) or RETURNING ---|
|<-- VEND VALID (0x15) --------------|
|<-- STACKED (0x16) -----------------|
|--- ACK (0x50) --------------------->|
|<-- IDLE (0x11) --------------------|
The driver polls at ~10 Hz (STATUS_REQUEST every 100 ms). On ESCROW, the
EscrowDecisionHook.decide() function (in validator_daemon.py) is consulted: by default it
returns STACK_1 for any recognized denomination unless the session has been explicitly
inhibited (operator lockout, tamper alarm, ledger-side session closure), in which case it
returns RETURN. Once STACKED is observed, the host publishes bill.stacked and sends ACK
(0x50) to return the validator to IDLE.
5.3.5 Alternative protocol: ccTalk
Some validator SKUs (e.g. certain Money Controls / Suzo-Happ ccTalk-native units) use ccTalk
instead of ID-003. The driver abstraction isolates this: ID003Driver implements a
ValidatorDriver protocol (initialize(), run(), stop(), and the ValidatorState
dataclass) that a CcTalkDriver class would implement identically, publishing the same
bill.stacked / bill.returned / validator.error events. Swapping protocols is a
configuration change (--protocol id003|cctalk) in moneyroll-hwd.service's ExecStart, not
a change to moneyroll-ledger or any downstream consumer, because the ZeroMQ event schema is
protocol-agnostic.
5.4 Tamper subsystem (STM32 real-time MCU)
5.4.1 Hardware interface
| Item | Value |
|---|---|
| MCU | STM32L452RET6 (ARM Cortex-M4, 512 KB flash, low-power) |
| Link | UART, 115200 8N1, dedicated isolated header (/dev/ttyAMA3) separate from the validator UART |
| Power | MCU has its own tamper battery (CR123A, 3 V) independent of main 24 V DC bus, so tamper detection persists when mains/main battery is cut |
| Sensors | Magnetic reed switches (cassette door, head enclosure, rear panel), 3-axis accelerometer (LIS2DH12) for shock/tilt, light sensor inside cassette bay (detects enclosure breach in the dark) |
5.4.2 Framed binary protocol (host ↔ MCU)
+------+------+--------+------------------+---------+
| SYNC | LEN | MSGID | PAYLOAD | CRC16 |
| 0xA5 | 1B | 1B | 0..32 bytes | 2B |
+------+------+--------+------------------+---------+
| MSGID | Direction | Name | Payload |
|---|---|---|---|
| 0x01 | host→MCU | ARM | zone bitmask (2B) |
| 0x02 | host→MCU | DISARM | signed disarm token (see 5.4.3) |
| 0x03 | MCU→host | HEARTBEAT | uptime seconds (4B) |
| 0x04 | MCU→host | BREACH_DETECTED | zone id (1B), sensor code (1B) |
| 0x05 | host→MCU | FIRE_DYE_PACK | signed fire token (32B) |
| 0x06 | MCU→host | DYE_FIRED_ACK | zone id (1B) |
| 0x07 | MCU→host | NAK | reason code (1B) |
CRC-16 is the same CCITT variant as Section 5.3.1, computed over SYNC through PAYLOAD.
5.4.3 Arm/disarm state machine
power-on
|
v
[DISARMED] <----------------+
| ARM (host) |
v |
[ARMED] ---- BREACH ---> [ALARMED]
^ |
| DISARM (signed) | operator
+-----------------------+ reset (physical key)
DISARM requires a token signed by the host's session-signing key (Ed25519), validated by the
MCU against a public key burned into MCU flash at manufacturing time (st-flash provisioning
step, documented in Module 4). This prevents a compromised host process from silently disarming
tamper protection without an authenticated command chain.
5.4.4 Dye-pack fire authorization — two-key control
Firing the dye pack is destructive (renders cash unusable and is a compliance/liability event), so it requires two independent authorizations:
- MCU hardware arm: the MCU's own breach-detection state machine must independently be in
ALARMED(i.e. a physical sensor tripped) — the MCU will refuseFIRE_DYE_PACKif it has not itself observed a breach condition, regardless of what the host requests. - Host signed command: the host must additionally send
FIRE_DYE_PACKwith a 32-byte token that is an Ed25519 signature over"FIRE:" + zone_id + ":" + mcu_nonce, wheremcu_nonceis a random nonce the MCU included in its most recentBREACH_DETECTEDmessage. This binds the authorization to a specific, current breach event and prevents replay.
Only when both conditions hold does the MCU trigger the dye-pack squib driver circuit and reply
DYE_FIRED_ACK.
5.4.5 Event journal
Every tamper MCU message (ARM, DISARM, BREACH_DETECTED, FIRE_DYE_PACK, DYE_FIRED_ACK, NAK) is
appended by moneyroll-hwd to an append-only, fsync'd journal at
/var/lib/moneyroll/tamper-journal.ndjson (newline-delimited JSON), each entry additionally
hashed and chained (entry.prev_hash = sha256(previous_entry_bytes)) to make undetected
retroactive edits computationally evident. The journal is mirrored off-device once per hour over
the operator VPN as described in Module 6.
5.5 Deposit and fee ledger engine
Implemented in docs/engineering/firmware/fee_ledger.py. All monetary math uses Python's
decimal.Decimal (never floats) to avoid representation error.
5.5.1 Fee formula
Net Credit (USD) = Gross − 1.00 − (10.00 if card_requested else 0)
| Fee | Amount | Applies |
|---|---|---|
| Service fee | $1.00 flat | Every session |
| Card issuance fee | $10.00 flat | Only if the customer requests a physical NFC/EMV card |
| Bridge spread | 0.5% of the USD/XLM FX rate | Applied to the rate, not the USD amount: effective_rate = oracle_rate * 1.005 |
5.5.2 Rounding rules
- USD amounts (gross, fees, net_usd) are tracked to 2 decimal places (whole cents); bills are always integer denominations so no intermediate rounding of USD is needed.
net_xlm = net_usd / effective_rate, quantized to 7 decimal places (1 stroop, Stellar's smallest indivisible unit) usingROUND_DOWN— the terminal must never promise more XLM than the distributor wallet can deliver after truncation.
5.5.3 Settlement guard ("Complete Deposit")
The guard is evaluated only when the customer presses "Complete Deposit" — not during bill
acceptance. Cash already validated and stacked is accepted regardless of running total; the
validator is never inhibited purely for being under the fee threshold, because inhibiting an
in-flight escrow decision based on a total that has not yet reached a threshold would create
inconsistent UX (a $1 bill would be rejected while a customer intends to insert more). Guard
logic (Session.can_settle()):
| Condition | Result |
|---|---|
card_requested and gross < 11.00 | GuardFailure.INSUFFICIENT_FOR_CARD_FEE |
not card_requested and gross < 1.00 | GuardFailure.INSUFFICIENT_FOR_SERVICE_FEE |
destination account does not exist on-ledger and net_usd <= 0 | GuardFailure.BELOW_BASE_RESERVE_FOR_NEW_ACCOUNT |
| otherwise | None (proceed to settlement) |
The Python-side check for BELOW_BASE_RESERVE_FOR_NEW_ACCOUNT is a conservative pre-check only;
the authoritative reserve check happens in settle.ts against Horizon's live account state and
current base reserve, since the actual minimum balance requirement depends on the destination's
subentry count (trustlines, offers, signers) which the terminal cannot know in advance.
5.5.4 Worked examples
| Session | Bills | Card | Rate (USD/XLM) | Gross | Service fee | Card fee | Net USD | Effective rate | Net XLM | Guard |
|---|---|---|---|---|---|---|---|---|---|---|
| sess-0001 | $20 | No | 0.11 | $20.00 | $1.00 | $0.00 | $19.00 | 0.11055 | 171.8vvv (see script output) | PASS |
| sess-0002 | $100 + $50 | Yes | 0.11 | $150.00 | $1.00 | $10.00 | $139.00 | 0.11055 | computed at runtime | PASS |
| sess-0003 | $1 | No | 0.11 | $1.00 | $1.00 | $0.00 | $0.00 | 0.11055 | 0.0000000 | BELOW_BASE_RESERVE_FOR_NEW_ACCOUNT (if new account) else PASS with 0 credit |
| sess-0004 | $5 | Yes | 0.11 | $5.00 | $1.00 | $10.00 | — | — | — | INSUFFICIENT_FOR_CARD_FEE |
Run python3 docs/engineering/firmware/fee_ledger.py to print exact Decimal outputs for these
four examples (values depend on the live FX rate parameter passed in; the script uses 0.11 as an
illustrative rate).
5.5.5 Refund/return policy when the guard fails
Because bills are captured in the sealed steel cassette immediately on STACKED (Section 5.3.4)
and the mechanism has no un-stack/return path once a note is stacked, a guard failure cannot
be resolved by physically returning cash. Policy:
moneyroll-ledgertransitions the session toHELD(notFAILED) and prints a receipt with areceipt_code(12-character base32, HMAC-derived fromsession_id+ terminal secret) and instructions to contact operator support.- The session record (
gross_usd,bills[],receipt_code, timestamp, terminal id) is written to/var/lib/moneyroll/held-sessions.ndjsonand flagged for the operator dashboard. - Operator refund path: an authorized operator, using the operator console (Module 7), looks up
the
receipt_code, verifies photo ID against the terminal's session camera log, and issues a manual ACH/Stellar refund ofgross_usd(orgross_usdminus any applicable manual handling fee disclosed in the terminal's terms of service) directly to a customer-provided destination. HELDsessions never auto-retry settlement; they require explicit operator disposition to prevent an unauthorized top-up (e.g., a second customer inserting bills into a still-open low-balance session) from being silently credited to a stale card/address binding.
5.6 Stellar integration design
5.6.1 Network endpoints
| Network | Horizon | Soroban RPC | Passphrase |
|---|---|---|---|
| Mainnet | https://horizon.stellar.org | https://soroban-rpc.mainnet.stellar.org | Public Global Stellar Network ; September 2015 |
| Testnet | https://horizon-testnet.stellar.org | https://soroban-rpc.testnet.stellar.org | Test SDF Network ; September 2015 |
Soroban RPC is provisioned for future smart-contract-based settlement (e.g. an escrow contract
holding funds until a KYC oracle confirms) but is not used by the reference settle.ts, which
performs classic Horizon payment/createAccount operations only.
5.6.2 Distributor hot wallet and channel accounts
- The distributor account is the terminal's (or terminal fleet's) hot wallet, holding an XLM working balance plus a USDC trustline.
- Sequence-number management:
settle.tsreloads the distributor account viaserver.loadAccount()immediately before building each transaction, obtaining the current sequence number. Concurrent settlement across multiple terminals sharing one distributor would race on sequence numbers, so each physical terminal is provisioned its own channel account (a funded account whose sole purpose is signing as the transaction source, with the actual distributor as a co-signer or as the payment operation's implicit source viaOperation.payment({ source: distributorPublicKey, ... })in a multi-op transaction). This bounds sequence contention to one terminal at a time. This module's referencesettle.tsuses the single-distributor model appropriate to a single-terminal deployment; the channel-account variant is a straightforward extension (swapsourceAccountinbuildSettlementfor the terminal's channel account and add the distributor as a second signer on the payment op).
5.6.3 createAccount vs payment
| Destination state | Operation | Amount source |
|---|---|---|
| Does not exist on ledger | createAccount | netXlm becomes the starting balance; must be ≥ 1 XLM base reserve |
Exists, has USDC trustline to issuer GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN, and USDC was requested | payment (USDC) | netUsdc |
| Exists, no USDC trustline (or USDC not requested) | payment (XLM) | netXlm |
resolveDestination() in settle.ts accepts a plain G-address, an M-address (SEP-23 muxed
account), or a JSON blob emitted by the card dispenser ({"stellarAddress": "G...", "cardId": "..."}), normalizing all three to a {accountId, memoId?} pair.
5.6.4 Memo and timebounds
- Memo type:
MEMO_HASH, value =SHA-256("${terminalId}:${sessionId}"), 32 bytes. This binds every on-chain transaction to an off-chain session record without leaking any customer PII on-ledger, and gives the idempotency journal a deterministic, collision-resistant key. - Timebounds: 60 seconds (
TransactionBuilder.setTimeout(60)), chosen to be long enough to survive normal Horizon submission latency but short enough that a stuck/duplicated submission cannot be replayed hours later.
5.6.5 Retry policy and idempotency
| Failure | Handling |
|---|---|
tx_bad_seq | Reload distributor account sequence number, rebuild and re-sign an identical-intent transaction (same memo hash, same amount, same destination), resubmit |
tx_too_late | Poll Horizon by transaction hash (GET /transactions/{hash}) before resubmitting — the original may have landed just after the client's timeout |
| HTTP 504 / socket timeout | Same as tx_too_late: poll-by-hash first, only rebuild+resubmit if not found within pollByHash's wait window |
| Any other Horizon result code | Fail immediately; do not retry (e.g. tx_insufficient_balance, op_no_destination, op_no_trust) |
Every attempt is bounded by MAX_SUBMIT_ATTEMPTS = 5 with exponential backoff plus jitter
(backoffWithJitterMs). The persisted JSON journal (JOURNAL_PATH, default
./settlement-journal.json) keys entries by sessionId and stores the signed envelope XDR, so a
process restart mid-retry resumes by resubmitting the same envelope rather than constructing a
new one (unless a sequence rebuild was required, in which case a new envelope with the same memo
hash is built).
5.6.6 Horizon error taxonomy
result_codes.transaction | Meaning | Action |
|---|---|---|
tx_success | Applied | Record success |
tx_bad_seq | Sequence number stale | Reload sequence, rebuild, resubmit |
tx_too_late | Outside timebounds by the time it reached consensus | Poll by hash, else rebuild with fresh timebounds |
tx_too_early | Submitted before minTime | Should not occur (min time unset); treat as fatal |
tx_insufficient_balance | Distributor lacks funds | Fatal, alert operator (low hot-wallet balance) |
tx_insufficient_fee | Network base fee increased | Rebuild with higher fee (surge pricing), one extra retry |
tx_failed (operations[0] = op_no_destination) | Payment to nonexistent account without createAccount | Should not occur given loadOrDetectAccount; fatal, log defect |
tx_failed (op_no_trust / op_line_full) | USDC trustline missing/full | Fall back to XLM path (already handled in buildSettlement) |
tx_failed (op_underfunded) | Distributor underfunded for this specific op | Fatal, alert operator |
5.6.7 Offline queue
If Horizon is unreachable network-wide (WAN outage), moneyroll-ledger continues accepting
deposits (cash handling is independent of connectivity) and appends completed sessions to
/var/lib/moneyroll/settlement-queue.ndjson. moneyroll-stellar drains this queue on startup
and on a 30-second poll timer, feeding each entry through the same settle() path (which is
idempotent by session id via the journal), so no session is settled twice and none is lost.
5.7 Security
5.7.1 Key custody
| Deployment tier | Mechanism |
|---|---|
| Preferred (CM4 + TPM daughterboard) | Infineon SLB9670 TPM 2.0 over SPI; distributor secret sealed against a PCR policy (PCR 0,2,4,7 — firmware, option ROM, boot loader, Secure Boot state) using tpm2-tss/tpm2-tools; unsealed into a tmpfs-backed memory region only inside the isolated signer process |
| Fallback (no TPM fitted) | Microchip ATECC608B secure element over I2C; private key generated on-chip and never leaves the device; signing requests proxied to it via SIGNER_SOCKET |
| Development/testnet only | Plain DISTRIBUTOR_SECRET environment variable (never used in production per Module 6 policy MR-ENG-006 §3) |
5.7.2 Key ceremony (summary)
- Two authorized officers convene with the terminal offline and the TPM/ATECC in factory state.
- Officer A generates the keypair on-device (TPM
tpm2_create/ ATECCgenkey) such that the private key material never exists outside the secure element. - Officer B independently verifies the resulting public key against a printed QR code and signs a paper key-custody log.
- The public key (G-address) is registered as the terminal's distributor account and funded with the initial working balance from cold storage.
- No backup of the private key exists outside the secure element; recovery in case of hardware failure is via merchant-account re-provisioning, not key export, by design.
5.7.3 Signing service isolation
The SIGNER_SOCKET process runs as a separate systemd unit, separate Linux user, under a
dedicated seccomp profile that permits only the syscalls needed for TPM/ATECC I/O and Unix
domain socket I/O. moneyroll-stellar.service communicates with it only over the local Unix
socket, sending unsigned transaction XDR and receiving back a signature, never a secret key.
Full seccomp policy, AppArmor profile, and network egress rules are specified in Module 6
(OS Hardening & Physical Security), Section 6.4.
5.8 Test plan
5.8.1 Unit tests — fee ledger
Run:
python3 -m pytest docs/engineering/firmware/fee_ledger.py -v
Covers: test_gross_sum_matches_bills, test_quote_basic_no_card, test_quote_with_card,
test_guard_insufficient_for_service_fee, test_guard_insufficient_for_card_fee,
test_guard_passes_with_sufficient_gross, test_unsupported_denomination_rejected.
5.8.2 Fuzzing — ID-003 frame parser
python3 - <<'PYEOF'
import random
from docs.engineering.firmware.validator_daemon import ID003Frame
random.seed(1234)
crashes = 0
for _ in range(200000):
length = random.randint(0, 16)
buf = bytes(random.randrange(256) for _ in range(length))
try:
ID003Frame.decode(buf)
except ValueError:
pass
except Exception:
crashes += 1
print(f"unexpected exceptions: {crashes}")
assert crashes == 0
PYEOF
ID003Frame.decode() must raise only ValueError on malformed input (bad sync, length
mismatch, CRC mismatch, short buffer) — any other exception type is a defect.
5.8.3 Testnet integration test — settlement worker
cd docs/engineering/stellar
npm install # development only, not required by the reviewer per task scope
npm run build
export HORIZON_URL=https://horizon-testnet.stellar.org
export NETWORK_PASSPHRASE="Test SDF Network ; September 2015"
export TERMINAL_ID=TEST-TERMINAL-01
export JOURNAL_PATH=/tmp/moneyroll-journal.json
# 1. Generate a fresh distributor keypair and fund it via Friendbot.
node -e "
const { Keypair } = require('@stellar/stellar-sdk');
const kp = Keypair.random();
console.log('SECRET=' + kp.secret());
console.log('PUBLIC=' + kp.publicKey());
"
curl "https://friendbot.stellar.org/?addr=<PUBLIC_KEY_FROM_ABOVE>"
export DISTRIBUTOR_SECRET=<SECRET_FROM_ABOVE>
# 2. Generate a destination keypair (leave unfunded to exercise createAccount).
node -e "console.log(require('@stellar/stellar-sdk').Keypair.random().publicKey())"
# 3. Run the settlement CLI against testnet.
node dist/settle.js --dest <DEST_PUBLIC_KEY> --usd 19.0000000 --session $(uuidgen)
Expected: JSON result with "kind": "success", a 64-hex-char hash, and the destination account
now visible on https://horizon-testnet.stellar.org/accounts/<DEST_PUBLIC_KEY> with the
requested starting balance. Re-running the identical command with the same --session value
must return "kind": "already_settled" with the same hash (idempotency check).
5.9 Running the companion code files
| File | Command |
|---|---|
validator_daemon.py | python3 docs/engineering/firmware/validator_daemon.py --simulate --log-level DEBUG (no hardware needed) or --port /dev/ttyAMA2 on-device |
fee_ledger.py | python3 docs/engineering/firmware/fee_ledger.py for worked examples; pytest for tests |
settle.ts | npm run build && node dist/settle.js --dest <address> --usd <amount> --session <uuid> from docs/engineering/stellar/ |
package.json | npm install && npm run build && npm test from docs/engineering/stellar/ |