MR

Money Roll Engineering Package

Rev A · 2026-09-13 · Released for review

← Kiosk

Module 05

Firmware, Business Logic & Stellar Stack

Validator UART daemon, fee ledger engine ($1 service / $10 card), and the async Horizon settlement worker with createAccount / payment, memo hashes and retries.

Build files · ready to send to a shop

money-roll-terminal-software.zip

Production terminal software for the CM4 control unit: Python device daemon (ID-003/ccTalk validator, CRT-591 dispenser, tamper GPIO + dye-pack arm/fire, IMU), session + fee-ledger engine, WebSocket bridge for the kiosk UI, hardware simulator (--sim), systemd units, pi-gen kiosk image stage with hardening, eth0→4G failover, signed OTA updates. Settles cash sessions against POST /api/public/terminal/settle with X-Terminal-Key.

  • moneyroll_terminal/ — daemon.py, session.py, tamper.py, network.py, health.py, ws_server.py, protocol.py (single source of truth)
  • moneyroll_terminal/drivers/ — id003.py, cctalk.py, crt591.py, gpio.py, imu.py + simulated.py
  • ota/ — update.sh and pure-Python ed25519 verifier
  • systemd/ — moneyroll-daemon.service, moneyroll-kiosk.service, watchdog
  • image/ — pi-gen stage, harden.sh, NetworkManager profiles, terminal.yaml template
  • tests/ — 29 unit tests + smoke test (all passing)
Download zip · 58 KB

Module 5 — Firmware, Business Logic & Stellar Blockchain Stack

FieldValue
Document IDMR-ENG-005
RevisionA
Date2026-09-13
OwnerPrincipal Embedded Systems Engineer / Blockchain Solutions Architect
StatusReleased 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:

FilePurpose
docs/engineering/firmware/validator_daemon.pymoneyroll-hwd ID-003 bill validator driver
docs/engineering/firmware/fee_ledger.pyDeposit/fee arithmetic used inside moneyroll-ledger
docs/engineering/stellar/settle.tsmoneyroll-stellar settlement worker
docs/engineering/stellar/package.jsonNode package manifest for the settlement worker

5.2 Host software architecture

5.2.1 Platform

ItemValue
SBCRaspberry Pi Compute Module 4 (CM4104032, 4 GB RAM, 32 GB eMMC, Wi-Fi variant disabled)
Carrier boardCustom Money Roll I/O carrier (rev C) with isolated RS-232/RS-485/UART headers
OSDebian 12 (bookworm), 64-bit, linux-image-arm64
Init systemsystemd 252
Kiosk display10.1" capacitive touch panel, HDMI + USB-HID
RuntimePython 3.11 (hwd, ledger), Node.js 20 LTS (stellar worker), Chromium 124 (kiosk UI)

5.2.2 systemd services

UnitLanguageResponsibilityRestart policy
moneyroll-hwd.servicePython 3.11, asyncioOwns all serial/GPIO hardware: bill validator, tamper MCU UART, cassette lock solenoid, card dispenserRestart=always, RestartSec=2
moneyroll-ledger.servicePython 3.11, asyncioSession state machine, fee computation, guard enforcement, hands off completed sessions to the settlement workerRestart=always, RestartSec=2
moneyroll-stellar.serviceNode 20Consumes settlement requests, builds/signs/submits Stellar transactions, maintains idempotency journalRestart=on-failure, RestartSec=5, StartLimitBurst=10
moneyroll-ui.serviceChromium (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:

PatternEndpoint(s)DirectionUse
PUB/SUBtcp://127.0.0.1:5556 (hwd → ledger), tcp://127.0.0.1:5558 (ledger → stellar)Fire-and-forget event streamBill events, tamper events, session-completed events
REQ/REPtcp://127.0.0.1:5557 (ledger ↔ hwd), tcp://127.0.0.1:5559 (ui ↔ ledger)Synchronous command/responseInhibit 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:

FieldTypeDescription
typestring"bill.stacked"
denominationintegerUSD face value, one of 1/2/5/10/20/50/100
serialstring | nullBill serial number if OCR is fitted (not standard hardware)
tsnumberUnix epoch seconds, float

hwd → ledger, topic bill.returned:

FieldTypeDescription
typestring"bill.returned"
denominationinteger | nullUnknown if rejected before recognition
serialstring | null
tsnumber

hwd → ledger, topic validator.error:

FieldTypeDescription
typestring"validator.error"
codestringOne of the ID-003 error group names (Section 5.3.2)
tsnumber

hwd → ledger, topic tamper.event:

FieldTypeDescription
typestring"tamper.event"
eventstringARMED | DISARMED | BREACH_DETECTED | DYE_FIRED | MCU_HEARTBEAT_LOST
zonestringPhysical zone id, e.g. CASSETTE_DOOR, HEAD_ENCLOSURE
tsnumber

ledger → stellar, topic session.completed:

FieldTypeDescription
typestring"session.completed"
session_idstringUUID v4
destinationstringG-address, M-address, or card metadata JSON string
net_usdstringDecimal string, 2 dp
net_xlmstringDecimal string, 7 dp
net_usdcstring | nullDecimal string if USDC path selected
card_issuedboolean
tsnumber

ui ↔ ledger REQ/REP, request quote:

FieldTypeDescription
cmdstring"quote"
session_idstring
card_requestedboolean

Response:

FieldTypeDescription
okboolean
gross_usdstring
service_fee_usdstring
card_fee_usdstring
net_usdstring
net_xlmstring
guardstring | nullnull if settlement is permitted, else a GuardFailure value

ledger ↔ hwd REQ/REP, request inhibit:

FieldTypeDescription
cmdstring"inhibit"
valuebooleantrue 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 — always 0xFC.
  • 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 (polynomial 0x1021, initial value 0x0000, no input/output reflection, no final XOR), computed over every byte from SYNC through the end of DATA, transmitted little-endian (low byte first). See crc16_ccitt() in validator_daemon.py.

5.3.2 Status codes

CodeNameGroup
0x11ENABLE / IDLENormal
0x12ACCEPTINGNormal
0x13ESCROWNormal
0x14STACKINGNormal
0x15VEND VALIDNormal
0x16STACKEDNormal
0x17REJECTINGNormal
0x18RETURNINGNormal
0x19HOLDINGNormal
0x1ADISABLE / INHIBITNormal
0x1BINITIALIZENormal
0x40POWER UPPower-up group
0x41POWER UP WITH BILL IN ACCEPTORPower-up group
0x42POWER UP WITH BILL IN STACKERPower-up group
0x43STACKER FULLError group
0x44STACKER OPENError group
0x45ACCEPTOR JAMError group
0x46STACKER JAMError group
0x47PAUSEError group
0x48CHEATEDError group
0x49FAILUREError group
0x4ACOMMUNICATION ERRORError group
0x4BINVALID COMMANDError 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 byteUSD 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

ItemValue
MCUSTM32L452RET6 (ARM Cortex-M4, 512 KB flash, low-power)
LinkUART, 115200 8N1, dedicated isolated header (/dev/ttyAMA3) separate from the validator UART
PowerMCU 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
SensorsMagnetic 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    |
+------+------+--------+------------------+---------+
MSGIDDirectionNamePayload
0x01host→MCUARMzone bitmask (2B)
0x02host→MCUDISARMsigned disarm token (see 5.4.3)
0x03MCU→hostHEARTBEATuptime seconds (4B)
0x04MCU→hostBREACH_DETECTEDzone id (1B), sensor code (1B)
0x05host→MCUFIRE_DYE_PACKsigned fire token (32B)
0x06MCU→hostDYE_FIRED_ACKzone id (1B)
0x07MCU→hostNAKreason 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:

  1. 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 refuse FIRE_DYE_PACK if it has not itself observed a breach condition, regardless of what the host requests.
  2. Host signed command: the host must additionally send FIRE_DYE_PACK with a 32-byte token that is an Ed25519 signature over "FIRE:" + zone_id + ":" + mcu_nonce, where mcu_nonce is a random nonce the MCU included in its most recent BREACH_DETECTED message. 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)
FeeAmountApplies
Service fee$1.00 flatEvery session
Card issuance fee$10.00 flatOnly if the customer requests a physical NFC/EMV card
Bridge spread0.5% of the USD/XLM FX rateApplied 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) using ROUND_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()):

ConditionResult
card_requested and gross < 11.00GuardFailure.INSUFFICIENT_FOR_CARD_FEE
not card_requested and gross < 1.00GuardFailure.INSUFFICIENT_FOR_SERVICE_FEE
destination account does not exist on-ledger and net_usd <= 0GuardFailure.BELOW_BASE_RESERVE_FOR_NEW_ACCOUNT
otherwiseNone (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

SessionBillsCardRate (USD/XLM)GrossService feeCard feeNet USDEffective rateNet XLMGuard
sess-0001$20No0.11$20.00$1.00$0.00$19.000.11055171.8vvv (see script output)PASS
sess-0002$100 + $50Yes0.11$150.00$1.00$10.00$139.000.11055computed at runtimePASS
sess-0003$1No0.11$1.00$1.00$0.00$0.000.110550.0000000BELOW_BASE_RESERVE_FOR_NEW_ACCOUNT (if new account) else PASS with 0 credit
sess-0004$5Yes0.11$5.00$1.00$10.00INSUFFICIENT_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:

  1. moneyroll-ledger transitions the session to HELD (not FAILED) and prints a receipt with a receipt_code (12-character base32, HMAC-derived from session_id + terminal secret) and instructions to contact operator support.
  2. The session record (gross_usd, bills[], receipt_code, timestamp, terminal id) is written to /var/lib/moneyroll/held-sessions.ndjson and flagged for the operator dashboard.
  3. 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 of gross_usd (or gross_usd minus any applicable manual handling fee disclosed in the terminal's terms of service) directly to a customer-provided destination.
  4. HELD sessions 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

NetworkHorizonSoroban RPCPassphrase
Mainnethttps://horizon.stellar.orghttps://soroban-rpc.mainnet.stellar.orgPublic Global Stellar Network ; September 2015
Testnethttps://horizon-testnet.stellar.orghttps://soroban-rpc.testnet.stellar.orgTest 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.ts reloads the distributor account via server.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 via Operation.payment({ source: distributorPublicKey, ... }) in a multi-op transaction). This bounds sequence contention to one terminal at a time. This module's reference settle.ts uses the single-distributor model appropriate to a single-terminal deployment; the channel-account variant is a straightforward extension (swap sourceAccount in buildSettlement for the terminal's channel account and add the distributor as a second signer on the payment op).

5.6.3 createAccount vs payment

Destination stateOperationAmount source
Does not exist on ledgercreateAccountnetXlm becomes the starting balance; must be ≥ 1 XLM base reserve
Exists, has USDC trustline to issuer GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN, and USDC was requestedpayment (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

FailureHandling
tx_bad_seqReload distributor account sequence number, rebuild and re-sign an identical-intent transaction (same memo hash, same amount, same destination), resubmit
tx_too_latePoll Horizon by transaction hash (GET /transactions/{hash}) before resubmitting — the original may have landed just after the client's timeout
HTTP 504 / socket timeoutSame as tx_too_late: poll-by-hash first, only rebuild+resubmit if not found within pollByHash's wait window
Any other Horizon result codeFail 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.transactionMeaningAction
tx_successAppliedRecord success
tx_bad_seqSequence number staleReload sequence, rebuild, resubmit
tx_too_lateOutside timebounds by the time it reached consensusPoll by hash, else rebuild with fresh timebounds
tx_too_earlySubmitted before minTimeShould not occur (min time unset); treat as fatal
tx_insufficient_balanceDistributor lacks fundsFatal, alert operator (low hot-wallet balance)
tx_insufficient_feeNetwork base fee increasedRebuild with higher fee (surge pricing), one extra retry
tx_failed (operations[0] = op_no_destination)Payment to nonexistent account without createAccountShould not occur given loadOrDetectAccount; fatal, log defect
tx_failed (op_no_trust / op_line_full)USDC trustline missing/fullFall back to XLM path (already handled in buildSettlement)
tx_failed (op_underfunded)Distributor underfunded for this specific opFatal, 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 tierMechanism
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 onlyPlain DISTRIBUTOR_SECRET environment variable (never used in production per Module 6 policy MR-ENG-006 §3)

5.7.2 Key ceremony (summary)

  1. Two authorized officers convene with the terminal offline and the TPM/ATECC in factory state.
  2. Officer A generates the keypair on-device (TPM tpm2_create / ATECC genkey) such that the private key material never exists outside the secure element.
  3. Officer B independently verifies the resulting public key against a printed QR code and signs a paper key-custody log.
  4. The public key (G-address) is registered as the terminal's distributor account and funded with the initial working balance from cold storage.
  5. 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

FileCommand
validator_daemon.pypython3 docs/engineering/firmware/validator_daemon.py --simulate --log-level DEBUG (no hardware needed) or --port /dev/ttyAMA2 on-device
fee_ledger.pypython3 docs/engineering/firmware/fee_ledger.py for worked examples; pytest for tests
settle.tsnpm run build && node dist/settle.js --dest <address> --usd <amount> --session <uuid> from docs/engineering/stellar/
package.jsonnpm install && npm run build && npm test from docs/engineering/stellar/