ThermaChain · Module A
폐열 계량값이 어떻게 검증되어 온체인 토큰이 되는가. 실제 구현된 코드 기준.
물이 흐르고 → 계량기가 재고 → 엣지가 검증하고 → 체인이 정산한다. 네 단계입니다.
모듈 A는 세 번째 칸. 물리와 체인 사이의 유일한 다리이자, 신뢰가 만들어지는 지점입니다.
가장 중요한 로직입니다. IoT 게이트웨이가 보낸 요청 1건이 POST /api/v1/telemetry에서
8개 관문을 순서대로 통과해야 kWh로 기록됩니다. 순서가 설계의 핵심입니다 —
싼 검사 먼저, 인증 전에는 아무것도 DB에 쓰지 않습니다.
rejected_readings 테이블을 쓰레기로 채워 스토리지 비용을 태울 수 있습니다.
반대로 인증을 통과한 기기가 이상한 값을 보내면 그건 반드시 기록되어야 하는 사건입니다 —
계량기 고장이거나 조작 시도이기 때문입니다.
HMAC은 “게이트웨이가 그렇게 말했다”만 증명합니다. 물이 실제로 흘렀는지는 증명 못 합니다. 그 틈을 막는 게 이 층입니다.
| 공격 / 고장 | 어떻게 보이는가 | 막는 규칙 |
|---|---|---|
| 온도센서 2개를 서로 바꿔 끼움 | 루프가 멈춰 있어도 ΔT가 양수로 나옴 | TEMPERATURE_INVERTEDT_out ≤ T_in이면 거부 |
| 유량값을 부풀려 전송 | kWh가 선형으로 뻥튀기 | FLOW_EXCEEDS_RATED정격 유량 +10%까지만 |
| 유량·온도를 각각 그럴듯하게 조작 | 개별 값은 정상 범위 안 | POWER_EXCEEDS_RATED ★열원 정격 출력(kW)을 넘으면 거부 — 에너지 보존 |
| Pt1000 온도센서 단선 | −200℃ 또는 850℃ 같은 값 | TEMPERATURE_OUT_OF_RANGE |
| 같은 계량값 재전송 | 정상 서명이 붙어 있음 | REPLAYED_NONCEKV 캐시 + D1 UNIQUE 이중 방어 |
| 과거 계량값 저장했다 나중에 제출 | 서명 유효 | CLOCK_SKEW±300초 창 |
role·pair_device_id
컬럼이 그 교차검증을 위해 미리 열어둔 자리입니다.
매시 정각, 쌓인 검증 로그를 묶어 하나의 서명으로 만듭니다. 이 워커는 가스비를 쓰지 않습니다 — 서명만 만들고 끝냅니다.
timestamp ≥ now − 300s만 받고 (재전송 방어의 전제),
배치는 timestamp ≤ now − 300s만 닫습니다 (시계가 뒤처진 기기의 계량값이
두 배치로 쪼개지지 않게). 운영에서는 문제가 없습니다 — 한 시간 뒤 Cron이 돌 때 계량값은
이미 마감선을 한참 지나 있으니까요. 다만 넣자마자 배치를 돌리는 테스트는 기본 lag로는
영원히 아무것도 못 닫습니다. 그래서 수동 실행에만 ?lag=0 오버라이드를 뒀습니다.
Cron 경로에는 없습니다.
계량값을 1건씩 온체인에 올리면 트랜잭션 비용이 열 판매 수익을 넘습니다. 1분 간격 계량 = 하루 1,440건인데, 이걸 시간당 1건으로 묶으면 온체인 쓰기가 1/60이 됩니다. 대신 “묶었더니 개별 값이 안 보인다”는 문제가 생기는데, 머클루트가 그걸 해결합니다 — 나중에 특정 계량값 1건이 그 합계에 포함됐는지를 증명할 수 있어서, 분쟁이 말싸움이 아니라 검증이 됩니다.
테이블 4개. 관계는 단순합니다.
파일 12개, 순수 로직은 전부 테스트로 고정했습니다. 테스트 57개 통과 타입체크 통과
| 파일 | 역할 | 핵심 |
|---|---|---|
src/index.ts | Worker 진입점, 모든 라우트 | 02번 다이어그램의 8관문이 여기 순서대로 구현됨 |
src/energy.ts | 열량 계산 | Q=m·c·ΔT/3.6e6, 순간 출력 kW, kWh→wei(1e18) 변환 |
src/validation.ts | 물리 정합성 ★ | 거부 사유 13종을 타입으로 정의. 하드웨어 불신 층 |
src/hmac.ts | 요청 인증 | 정규 문자열 규격 + 상수시간 검증(subtle.verify) |
src/db.ts | D1 접근 | 전부 prepared statement. 배치 예약은 단일 트랜잭션 |
src/cron.ts | 배치 오라클 엔진 | 그룹핑 → 머클 → 예약 → 서명. 동시 실행 시 롤백 |
src/oracle.ts | 온체인 서명 | EIP-191 다이제스트, secp256k1, EIP-2 lowS 정규화 |
src/merkle.ts | 배치 커밋 | OpenZeppelin MerkleProof 호환 정렬 페어 해싱 |
src/abi.ts | ABI 인코딩 | uint256/address/bytes32만. ethers 없이 32바이트 워드 직접 생성 |
schema.sql | D1 스키마 | 테이블 4개 + 인덱스 5개 |
scripts/sign-request.mjs | 게이트웨이 시뮬레이터 | 펌웨어가 따라야 할 참조 구현. --loop 20으로 부하 생성 |
test/*.test.ts | 단위 테스트 57개 | 열량 수치, 서명 위조, 물리 조작 시나리오까지 |
| 엔드포인트 | 용도 |
|---|---|
POST /api/v1/telemetry | IoT 계량 수신 (핫패스) |
GET /api/v1/devices/:id/stats | 누적 kWh, 최근 온도 — 대시보드용 |
GET /api/v1/devices/:id/series | 온도·출력 시계열 그래프용 |
GET /api/v1/batches | 배치 목록 |
GET /api/v1/batches/:id/payload | 모듈 B 연결점 — 컨트랙트에 넣을 인자 그대로 반환 |
POST /api/v1/admin/devices | 기기 등록 + 시크릿 발급 |
POST /api/v1/admin/run-batch | Cron 수동 실행 (테스트용) |
| 믿는다 | 안 믿는다 | |
|---|---|---|
| 물리 | 열역학 법칙, 기기 정격표 | 게이트웨이가 보고한 숫자 |
| 엣지 | Cloudflare 런타임, WebCrypto | 요청 본문, 디바이스 시계 |
| 키 | Secure Element 안의 디바이스 키 KV·Secrets 안의 오라클 키 | 펌웨어 파일에 박힌 키 |
| 체인 | ecrecover, 머클 증명 | 제출자(누구든 가스만 내면 됨) |
마지막 줄이 중요합니다. 배치를 온체인에 올리는 주체는 아무나 될 수 있습니다 — 서명이 유효하지 않으면 컨트랙트가 거부하기 때문입니다. 그래서 이 워커는 지갑도 가스도 갖지 않고, 워커가 완전히 탈취돼도 공격자가 훔칠 자금이 없습니다. 훔칠 수 있는 건 서명 능력뿐이고, 그건 오라클 키 교체로 무효화됩니다.
# 1) 로컬 D1 생성 npm run db:local # 2) 워커 실행 npm run dev # 3) 기기 등록 (다른 터미널) curl -X POST http://127.0.0.1:8787/api/v1/admin/devices \ -H "authorization: Bearer local-dev-admin-token" \ -H "content-type: application/json" \ -d '{"id":"TC-SEOUL-DC01-A","supplier_id":1,"consumer_id":2, "secret":"dev-secret-please-change-me-0123456789abcdef", "max_flow_lpm":60,"max_thermal_kw":150}' # 4) 계량값 12건 전송 (서명 포함) node scripts/sign-request.mjs --loop 12 --interval 20 --jitter 1.5 # 5) 배치 마감 + 서명 (lag=0 — 04절 참조) curl -X POST "http://127.0.0.1:8787/api/v1/admin/run-batch?lag=0" \ -H "authorization: Bearer local-dev-admin-token" # 6) 모듈 B에 넣을 인자 확인 curl http://127.0.0.1:8787/api/v1/batches/1/payload # 거부 경로도 직접 확인해 보세요 node scripts/sign-request.mjs --flow 60 --tin 20 --tout 80 # 422 POWER_EXCEEDS_RATED node scripts/sign-request.mjs --tin 55 --tout 25 # 422 TEMPERATURE_INVERTED node scripts/sign-request.mjs --nonce dup && node scripts/sign-request.mjs --nonce dup # 두 번째 409 REPLAYED_NONCE
10.467 kWh → 오라클 서명 →
recoverSigner()가 서명자 주소를 정확히 복원. 거부 경로 5종도 실측 확인했습니다.