ThermaChain되는 방법 / 안 되는 방법쉬운 설명어디에 설치하나수익 모델법인 · 토큰 구조기술 구조발표 자료 (EN)

ThermaChain · Module A

Cloudflare Edge 오라클 — 로직 설명서

폐열 계량값이 어떻게 검증되어 온체인 토큰이 되는가. 실제 구현된 코드 기준.

01 전체 그림

물이 흐르고 → 계량기가 재고 → 엣지가 검증하고 → 체인이 정산한다. 네 단계입니다.

PHYSICAL 진공 히트파이프 서버·실외기 폐열 흡수 VIP 배관 이송 열교환기 → 온수 50~80℃ 이미 상용 기술 METERING 봉인 열량계 + 게이트웨이 유량 · T_in · T_out 측정 Modbus/M-Bus 수집 Secure Element로 HMAC 서명 ← 새로 만들어야 하는 부분 EDGE ORACLE — 모듈 A Cloudflare Workers + D1 HMAC 인증 · 재전송 차단 물리 정합성 검증 Q=m·c·ΔT 계산 · 배치 서명 지금 만든 것 BLOCKCHAIN — 모듈 B ThermaSettlement.sol 서명 검증 (onlyOracle) 1 kWh = 1 TCT 민팅 정산 · 탄소 증명 NFT 다음 단계 열(J) 센서 HTTPS 서명 flow, T_in, T_out, nonce, hmac kWh 합계 + merkle root ERC-20 토큰 + 정산 핵심: 체인은 “물이 흘렀다”를 직접 볼 수 없다. 엣지가 검증한 결과만 본다. 그래서 엣지의 검증 강도가 이 시스템 신뢰도의 상한이다.

모듈 A는 세 번째 칸. 물리와 체인 사이의 유일한 다리이자, 신뢰가 만들어지는 지점입니다.

02 계량값 한 건이 지나가는 관문

가장 중요한 로직입니다. IoT 게이트웨이가 보낸 요청 1건이 POST /api/v1/telemetry에서 8개 관문을 순서대로 통과해야 kWh로 기록됩니다. 순서가 설계의 핵심입니다 — 싼 검사 먼저, 인증 전에는 아무것도 DB에 쓰지 않습니다.

POST /api/v1/telemetry 1 JSON 파싱 본문이 JSON인가 400 MALFORMED_PAYLOAD DB 접근 없음 · 0원 2 스키마 검증 필드 타입 · NaN/Infinity · 64자 hex 서명 400 3 디바이스 조회 D1 등록된 기기인가 · 정격 용량 로드 404 UNKNOWN_DEVICE 403 DEVICE_NOT_ACTIVE 4 시계 오차 ±300초 밖이면 거부 (nonce 창의 전제) 400 CLOCK_SKEW ── 인증 경계 · 이 선 위에서는 D1에 아무것도 쓰지 않는다 ── 5 HMAC-SHA256 검증 KV 정규 문자열 재조립 후 상수시간 비교 401 BAD_SIGNATURE 6 Nonce 재전송 차단 KV 같은 계량값을 두 번 팔 수 없게 409 REPLAYED_NONCE ── 여기부터 거부해도 감사 로그에 남는다 (인증된 기기만) ── 7 물리 정합성 검증 ★ T_out>T_in · ΔT≤90℃ · 유량≤정격 · kW≤정격 422 + rejected_readings 기록 하드웨어를 안 믿는 방어선 8 Q 계산 + D1 저장 m = flow × interval/60 · Q = m·c·ΔT / 3.6e6 202 accepted energy_logs (batch_status='pending') 거부 이유는 전부 타입으로 정의돼 있음 (RejectReason)
왜 이 순서인가. 인증(5번) 전에 DB 쓰기를 하면, 시크릿 없는 공격자가 rejected_readings 테이블을 쓰레기로 채워 스토리지 비용을 태울 수 있습니다. 반대로 인증을 통과한 기기가 이상한 값을 보내면 그건 반드시 기록되어야 하는 사건입니다 — 계량기 고장이거나 조작 시도이기 때문입니다.

03 7번 관문이 실제로 막는 것

HMAC은 “게이트웨이가 그렇게 말했다”만 증명합니다. 물이 실제로 흘렀는지는 증명 못 합니다. 그 틈을 막는 게 이 층입니다.

공격 / 고장어떻게 보이는가막는 규칙
온도센서 2개를 서로 바꿔 끼움루프가 멈춰 있어도 ΔT가 양수로 나옴TEMPERATURE_INVERTED
T_out ≤ T_in이면 거부
유량값을 부풀려 전송kWh가 선형으로 뻥튀기FLOW_EXCEEDS_RATED
정격 유량 +10%까지만
유량·온도를 각각 그럴듯하게 조작개별 값은 정상 범위 안POWER_EXCEEDS_RATED
열원 정격 출력(kW)을 넘으면 거부 — 에너지 보존
Pt1000 온도센서 단선−200℃ 또는 850℃ 같은 값TEMPERATURE_OUT_OF_RANGE
같은 계량값 재전송정상 서명이 붙어 있음REPLAYED_NONCE
KV 캐시 + D1 UNIQUE 이중 방어
과거 계량값 저장했다 나중에 제출서명 유효CLOCK_SKEW
±300초 창
솔직하게 남는 한계. 위 규칙 전부를 지키면서 정격 이내로 조금씩 거짓말하는 게이트웨이는 소프트웨어로 못 잡습니다. 이건 DePIN의 근본 문제(last-mile oracle problem)이고, 해법은 코드가 아니라 하드웨어입니다 — 봉인된 법정 열량계, 키를 꺼낼 수 없는 Secure Element, 그리고 공급측·수요측 이중 계량 교차검증. 스키마의 role·pair_device_id 컬럼이 그 교차검증을 위해 미리 열어둔 자리입니다.

04 Cron 배치 엔진 — 어떻게 온체인으로 넘어가는가

매시 정각, 쌓인 검증 로그를 묶어 하나의 서명으로 만듭니다. 이 워커는 가스비를 쓰지 않습니다 — 서명만 만들고 끝냅니다.

Cron: 0 * * * * ① 마감선 계산 cutoff = now − lag(300s) ② pending 로그 그룹핑 공급자–소비자 쌍 단위로 GROUP BY supplier, consumer ③ 합계 + 머클루트 Σ kWh · keccak256 트리 ④ 배치 예약 (트랜잭션) batches INSERT + 로그 잠금 claimed ≠ 예상 → 롤백 ⑤ 서명할 다이제스트 (abi.encode) [0] DOMAIN "ThermaChain.EnergyBatch.v1" [1] chainId 체인 간 재사용 차단 [2] contract 배포본 간 재사용 차단 [3] batchId [4] supplierId [5] consumerId [6] totalKwhWei 1 kWh = 1e18 [7] periodEnd [8] stateRoot 머클 커밋 keccak256 → EIP-191 → secp256k1 65바이트 r‖s‖v · lowS 정규화 ⑥ 결과물 batches 행 1개 chain_status = 'signed' → 제출자가 가져가서 recordEnergyBatch() GET /batches/:id/payload 워커는 개인키로 서명만 함. 가스도, 지갑 잔고도 없음. → 워커가 털려도 자금 손실 0
수집 창과 마감 창은 일부러 겹치지 않습니다. 수집은 timestamp ≥ now − 300s만 받고 (재전송 방어의 전제), 배치는 timestamp ≤ now − 300s만 닫습니다 (시계가 뒤처진 기기의 계량값이 두 배치로 쪼개지지 않게). 운영에서는 문제가 없습니다 — 한 시간 뒤 Cron이 돌 때 계량값은 이미 마감선을 한참 지나 있으니까요. 다만 넣자마자 배치를 돌리는 테스트는 기본 lag로는 영원히 아무것도 못 닫습니다. 그래서 수동 실행에만 ?lag=0 오버라이드를 뒀습니다. Cron 경로에는 없습니다.

왜 배치로 묶는가

계량값을 1건씩 온체인에 올리면 트랜잭션 비용이 열 판매 수익을 넘습니다. 1분 간격 계량 = 하루 1,440건인데, 이걸 시간당 1건으로 묶으면 온체인 쓰기가 1/60이 됩니다. 대신 “묶었더니 개별 값이 안 보인다”는 문제가 생기는데, 머클루트가 그걸 해결합니다 — 나중에 특정 계량값 1건이 그 합계에 포함됐는지를 증명할 수 있어서, 분쟁이 말싸움이 아니라 검증이 됩니다.

05 데이터 모델

테이블 4개. 관계는 단순합니다.

devices id (PK) supplier_id / consumer_id api_key_hash max_flow_lpm max_thermal_kw role / pair_device_id meter_model / serial status 노란 필드 = 7번 관문이 쓰는 정격값 energy_logs id (PK) device_id → devices.id m_flow / t_in / t_out kwh_calculated timestamp / received_at UNIQUE(device_id, nonce) batch_status → batch_id 빨간 줄 = 재전송 최종 방어선 batches id (PK) supplier_id / consumer_id total_kwh_wei state_root (머클) digest / oracle_signature chain_status / tx_hash period_start / period_end 모듈 B가 그대로 받아쓰는 값 1:N N:1 rejected_readings — 거부 감사 로그

06 코드로 뭘 짰나

파일 12개, 순수 로직은 전부 테스트로 고정했습니다. 테스트 57개 통과 타입체크 통과

파일역할핵심
src/index.tsWorker 진입점, 모든 라우트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.tsD1 접근전부 prepared statement. 배치 예약은 단일 트랜잭션
src/cron.ts배치 오라클 엔진그룹핑 → 머클 → 예약 → 서명. 동시 실행 시 롤백
src/oracle.ts온체인 서명EIP-191 다이제스트, secp256k1, EIP-2 lowS 정규화
src/merkle.ts배치 커밋OpenZeppelin MerkleProof 호환 정렬 페어 해싱
src/abi.tsABI 인코딩uint256/address/bytes32만. ethers 없이 32바이트 워드 직접 생성
schema.sqlD1 스키마테이블 4개 + 인덱스 5개
scripts/sign-request.mjs게이트웨이 시뮬레이터펌웨어가 따라야 할 참조 구현. --loop 20으로 부하 생성
test/*.test.ts단위 테스트 57개열량 수치, 서명 위조, 물리 조작 시나리오까지

API

엔드포인트용도
POST /api/v1/telemetryIoT 계량 수신 (핫패스)
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-batchCron 수동 실행 (테스트용)

07 신뢰 경계 — 무엇을 믿고 무엇을 안 믿나

믿는다안 믿는다
물리열역학 법칙, 기기 정격표게이트웨이가 보고한 숫자
엣지Cloudflare 런타임, WebCrypto요청 본문, 디바이스 시계
Secure Element 안의 디바이스 키
KV·Secrets 안의 오라클 키
펌웨어 파일에 박힌 키
체인ecrecover, 머클 증명제출자(누구든 가스만 내면 됨)

마지막 줄이 중요합니다. 배치를 온체인에 올리는 주체는 아무나 될 수 있습니다 — 서명이 유효하지 않으면 컨트랙트가 거부하기 때문입니다. 그래서 이 워커는 지갑도 가스도 갖지 않고, 워커가 완전히 탈취돼도 공격자가 훔칠 자금이 없습니다. 훔칠 수 있는 건 서명 능력뿐이고, 그건 오라클 키 교체로 무효화됩니다.

08 지금 바로 돌려보기

# 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
실제로 돌려서 확인한 결과입니다. 위 흐름 전체가 로컬에서 동작합니다 — 계량값 12건 → 배치 1건 마감 → 10.467 kWh → 오라클 서명 → recoverSigner()가 서명자 주소를 정확히 복원. 거부 경로 5종도 실측 확인했습니다.