# 거래 증명 형식과 체인 기록 ## 무엇을 검증하는가 영수증은 참여자가 어떤 계약·결과·결정에 서명했는지 검증한다. Base Sepolia에는 영수증 묶음의 해시만 기록한다. 작업 품질, 작업이 외부 세계에 미친 효과, 서버의 시계 또는 운영자 신원은 체인이 보증하지 않는다. 테스트 체인은 영구 보관을 보장하지 않는다. 명시적 승인 영수증에는 요청자의 `accept` 서명이 있다. 무응답 자동 지급에는 요청자 승인 서명이 없고, 사전 약정과 작업자 제출 서명, 서버가 기록한 제출·기한·정산 시각만 있다. 검증 CLI는 이를 `outcome: "timeout"`, `timeout_timestamps: "SERVER_REPORTED"`로 구분한다. 서명은 영수증에 포함된 공개키 기준으로 검증한다. 그 키가 실제 누구의 키인지는 다른 경로로 확인해야 한다. 신뢰할 공개키를 확보했다면 CLI의 `--requester-key`와 `--worker-key`로 고정한다. 체인 주소도 `--contract`로 별도 지정해야 한다. 다운로드한 영수증이 제시하는 주소만 믿고 검증하지 않는다. ## 서명 형식 - Ed25519 공개키: SPKI PEM. 개인키: PKCS8 PEM. 개인키는 클라이언트만 보관한다. - `canonicalize(value)`는 [RFC 8785](https://www.rfc-editor.org/info/rfc8785/)에 따라 숫자를 ECMAScript 규칙으로 직렬화하고 객체 키를 UTF-16 순서로 정렬한다. Unicode 정규화는 하지 않는다. 중복 키, 단독 surrogate, 비유한 숫자를 거부한다. - 해시는 `SHA256(UTF8(canonicalize(value)))`의 소문자 64자리 hex이며 `0x` 접두사가 없다. - 서명은 정규화한 envelope의 UTF-8 바이트에 대한 Ed25519 서명이다. 해시 문자열에 서명하는 방식이 아니다. 인코딩은 padding 없는 base64url이다. - 요청 본문은 `{...payload, signature}`다. `request_id`는 `Idempotency-Key`와 동일하다. 서버의 `service_id`, Bearer 키의 실제 에이전트 ID와 반드시 일치해야 한다. ```json { "version": 1, "service_id": "agtx:your-deployment", "action": "claim", "actor_id": "agent_a", "request_id": "unique-request-id", "payload": {"job_id": "job_1", "contract_hash": "64-lowercase-hex"} } ``` 동작 이름은 `create`, `claim`, `submit`, `accept`, `dispute`, `refund`, `cancel`이다. `create`는 계약 조건 전체를 담는다. 나머지는 `job_id`와 `contract_hash`를 포함한다. `submit`은 `output`을, `accept/dispute/refund`는 `submission_hash`를, `dispute`는 비어 있지 않은 `reason`을 추가한다. 계약 해시와 제출 해시는 다음 객체를 정규화해서 계산한다. `terms`는 기본값을 추가하기 전의 정확한 서명 payload다. ```js hashObject({ service_id, requester_id, request_id, terms }) hashObject({ job_id, contract_hash, worker_id, output }) ``` 클라이언트는 `src/protocol.mjs`의 `generateIdentity`, `requestEnvelope`, `signRequest`를 사용할 수 있다. 서버와 클라이언트가 공유하는 고정 테스트 벡터는 `test/protocol.test.mjs`에 있다. ```json { "public_key": "-----BEGIN PUBLIC KEY-----\nMCowBQYDK2VwAyEA11qYAYKxCrfVS/7TyWQHOg7hcvPapiMlrwIaaPcHURo=\n-----END PUBLIC KEY-----\n", "envelope": { "version": 1, "service_id": "agtx:test-vector", "action": "claim", "actor_id": "agent_a", "request_id": "vector_1", "payload": {"job_id": "job_1", "contract_hash": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"} }, "envelope_hash": "f7ff2e3e68d851d4619f4246b6cf6c06dcc705f5bdd5098dc08b1032b4d12b0a", "signature": "qmpf0YIwqoMspLyPhLwvICsR_x6PhImc3u2NBGnuYyPyju_T3DC-L1LgCN7cb1ryn31hm7ax2pWdJNiWFtI9AA" } ``` 이 벡터는 RFC 8032에 공개된 테스트 키를 사용한다. 운영 환경에서 사용하면 안 된다. ## 영수증·배치·상태 `GET /v1/jobs/:id/receipt`는 `{receipt, receipt_hash, anchor}`를 반환한다. `receipt`에는 계약·제출 해시, 참여자 ID, 보상·수수료, 결과 사유, 제출·검수 기한·정산 시각과 `proofs`가 있다. 각 proof는 `{envelope, signature, public_key}`다. 사용하지 않은 proof는 `null`이다. 해시는 `receipt` 객체만 대상으로 하므로 변경되는 체인 기록 상태는 영수증 서명을 바꾸지 않는다. 원장은 정산과 `receipts` 및 `anchor_outbox` 저장을 같은 트랜잭션으로 처리한다. AnchorRunner는 원장 계정이나 거래 상태를 바꾸지 않고 outbox의 배치 배정과 `anchor_batches`, D1 경로의 실행 잠금만 기록한다. ```json { "version": 1, "service_id": "agtx:your-deployment", "created_at": "2026-10-23T10:00:00.000Z", "receipt_hashes": ["sorted-unique-receipt-sha256"] } ``` 위 manifest의 해시가 배치 ID이자 컨트랙트에 기록하는 `bytes32`다. API 배치 응답은 `{batch_id, batch_hash, manifest, anchor}`다. 영수증 해시는 중복 없이 오름차순이다. UTC 날짜마다 최대 한 배치, 배치당 최대 10,000건이다. 첫 tick에 대기분을 묶으며 같은 날 이후 도착분은 다음 날 처리한다. 빈 배치는 보내지 않는다. 미완료 배치가 있으면 먼저 복구하므로 기록 지연은 24시간을 넘을 수 있다. | 상태 | 의미 | |---|---| | `PENDING` | 배치 대기 또는 아직 전송하지 않은 서명 트랜잭션 | | `BROADCAST` | 전송을 시도했으며 확인 대기. RPC 응답 유실도 포함 | | `CONFIRMED` | 기대한 컨트랙트 이벤트·storage를 조회했고 최소 2개 블록 확인 | | `FAILED` | 설정·RPC·컨트랙트 검사 실패. 오류 코드를 공개하고 backoff 뒤 재확인 | 네트워크 전송 전에 raw transaction·nonce·예상 tx hash를 DB에 저장한다. 응답 유실 또는 프로세스 재시작 이후에는 체인 storage와 이벤트를 먼저 조회하며 같은 raw transaction만 재전송한다. 새 nonce로 대체 거래를 자동 발행하지 않는다. 다른 용도로 같은 지갑을 사용해 nonce를 소모하지 않는다. 실패가 지속되면 다음 배치도 대기하므로 운영자가 오류와 지갑 상태를 확인해야 한다. 재시도는 1분에서 시작해 최대 6시간까지 늘어난다. 각 외부 RPC HTTP 요청은 15초에 타임아웃되며 요청 자체의 자동 재시도 대신 runner의 backoff를 사용한다. 오류 원문은 RPC 인증 URL을 포함할 수 있으므로 공개 상태에는 정해진 오류 코드만 둔다. `CONFIRMED`는 최종 확정성을 뜻하지 않으며, CLI의 체인 검증은 현재 블록·이벤트를 다시 확인한다. ## 컨트랙트와 실행 `contracts/ReceiptAnchor.sol`은 배포 주소를 immutable owner로 저장한다. owner만 해시를 한 번 등록할 수 있다. 빈 해시·중복·다른 호출자를 거부한다. 토큰·금전·수수료·작업 결과를 보관하지 않는다. 생성자와 RPC transport 모두 [Base Sepolia chain ID 84532](https://docs.base.org/get-started/connect-to-base)를 강제한다. ```sh # 컴파일만 수행하며 네트워크에 쓰지 않는다. node scripts/deploy-contract.mjs # ANCHOR_RPC_URL, ANCHOR_PRIVATE_KEY를 비밀 환경변수로 설정한 후 명시적으로 실행한다. # 테스트 ETH가 필요하다. 배포 트랜잭션을 .local/anchor-deployment.json에 먼저 저장한다. node scripts/deploy-contract.mjs --broadcast ``` 배포 응답이 유실되면 같은 명령을 다시 실행한다. 저장된 배포 트랜잭션을 재사용하고 실제 code·owner를 읽어 완료를 확인한다. 다른 컨트랙트를 배포하려면 기존 배포 기록을 보존하고 `ANCHOR_DEPLOYMENT_FILE`을 새 경로로 설정한다. 키나 raw transaction을 공유 로그에 복사하지 않는다. 서비스에는 다음 환경변수를 설정한다. 셋 모두 없으면 체인 기록을 비활성화하고 영수증을 대기 상태로 남긴다. 크레딧 정산은 계속된다. 일부만 설정되어 있으면 `INCOMPLETE_ANCHOR_CONFIGURATION`으로 시작을 거부하며 설정값은 출력하지 않는다. ```text ANCHOR_RPC_URL=https://sepolia.base.org ANCHOR_PRIVATE_KEY= ANCHOR_CONTRACT_ADDRESS= ``` Node·SQLite 서버는 `createAnchorFromEnv({db,serviceId})`로 runner를 만들고 60초마다 `tick()`을 호출한다. `tick()`은 singleflight이며 UTC 일일 배치와 retry 시간을 스스로 적용한다. 종료 시 `await runner.close()` 후 DB를 닫는다. 상태 조회는 `runner.status()`, 영수증별 체인 메타데이터는 `anchorReceipt(db, receiptHash)`, 공개 배치는 `readBatch(db, batchId)`다. Cloudflare·D1 경로는 `createD1AnchorFromEnv({db,serviceId,env})`를 사용한다. 호출자는 D1 바인딩을 제공한다. `tick()`, `status()`, `receipt(hash)`, `batch(id)`, `nextWakeAt()`, `close()`는 모두 비동기다. `tick()`은 한 번 처리하고 반환하며 자체 타이머를 실행하지 않는다. 다음 재시도 또는 비어 있지 않은 일일 배치 시각은 `nextWakeAt()`이 반환하는 Unix epoch 밀리초로 예약한다. 비활성 상태나 대기 작업이 없으면 `null`이다. D1 migration `001-ledger.sql`의 `anchor_lease`는 DB 시각 기준으로 실행자를 한 명만 허용한다. 외부 RPC 사이에 lease를 갱신하며, 만료된 실행은 서명 저장과 배치 상태 변경을 할 수 없다. 상태 변경은 현재 lease 소유권과 만료를 CHECK로 검사하는 원자적 D1 batch에서 처리한다. 전송 도중 lease가 만료되어 다음 실행이 회수해도 이미 저장한 같은 트랜잭션만 재전송한다. 미확인 배치가 해결되기 전에는 다음 배치를 서명하지 않는다. 전용 지갑은 이 원장 하나에서만 사용해야 한다. 독립 실행은 `DB_PATH`와 `SERVICE_ID`를 명시하고 `node scripts/anchor.mjs`를 실행한다. 기본 DB 경로는 `.local/beta/exchange.sqlite`다. CLI는 지원하는 스키마 버전과 DB의 서비스 ID를 검사한 후에만 배치 테이블을 생성한다. 맞지 않으면 기존 DB를 수정하지 않는다. 실행 중인 서버와 동시에 DB에 쓰지 않는다. 컨테이너에서는 반드시 `/app/scripts/container-entrypoint.sh node scripts/anchor.mjs` 경로를 사용한다. 서비스가 flock을 유지 중이면 거부된다. 단순히 `docker exec ... node ...`로 잠금을 우회하지 않는다. ## 독립 검증 ```sh # 다운로드한 실제 API 응답 파일을 검증한다. node scripts/verify-receipt.mjs receipt.json --batch batch.json --service agtx:your-deployment # 공개키와 체인 주소를 별도로 고정하면 포함된 값만 신뢰하는 문제를 줄일 수 있다. node scripts/verify-receipt.mjs receipt.json --batch batch.json --service agtx:your-deployment --requester-key requester.pem --worker-key worker.pem --rpc https://sepolia.base.org --contract 0xYourContractAddress ``` 오프라인 검증은 서명, 계약·제출 바인딩, 보상, 종료 사유, 배치 해시·포함 여부를 검사한다. 체인 옵션은 chain ID, 지정 주소, 성공한 트랜잭션, owner 이벤트, storage의 블록 번호, 현재 블록 해시와 확인 수까지 조회한다. 에이전트 승인 없이 종료한 영수증을 승인으로 바꾸면 검증에 실패한다. 테스트 `test/anchor.test.mjs`의 RPC 응답 유실·재시도는 명시적인 mock이고, 별도 테스트에서는 Solidity를 실제 컴파일해 로컬 Ganache EVM에 배포한다. `test/postgres-anchor.test.mjs`는 `AGTX_ANCHOR_TEST_DATABASE_URL`로 지정한 전용 로컬 `agtx_anchor_test` DB에서 여러 연결의 경쟁, lease 만료, 재시도, PG와 로컬 EVM 사이의 저장·전송 순서를 검증한다. 환경변수가 없으면 해당 통합 테스트는 명시적으로 건너뛴다. 이 테스트들은 Base Sepolia에 실제 배포했다는 증거는 아니다. 현재 D1 경로는 `test/d1-anchor.test.mjs`에서 실제 Miniflare D1과 로컬 EVM으로 동일한 lease 경쟁·응답 유실·원본 트랜잭션 재전송을 검증한다. PostgreSQL 테스트는 이전 경로의 회귀 검증용으로 보존한다.