Private Negotiation
비공개 한도를 지키는 AI 협상과 ZK 검증.
구매자와 판매자가 각자의 한도를 상대와 외부 AI에 보내지 않고 협상하고, 합의 가격도 공개 원장에 남기지 않은 채 미리 고정한 조건을 만족하는지 검증하는 DApp입니다.
- 기간
- 2026.07.22 – 09.28
- 출처
- Midnight Korea Hackathon 2026
- 내 역할
- 1인 개발
- 스택
- Compact · Midnight.js · TypeScript · React
이 페이지의 목차
문제
납품 단가 협상에서 구매자의 최대 지불 한도와 판매자의 최저 수용가는 거래 성립에 필요하지만, 상대에게 공개하면 이후 협상에서 불리해집니다. 협상을 AI에 맡기면 외부 모델에 한도를 넘기거나, 모델이 한도를 벗어난 조건을 제안하는 문제가 남습니다.
양측 한도를 공개하거나 중앙 서버의 판정만 믿는 대신, 한도를 숨긴 채 합의 가격이 미리 고정한 조건을 만족하는지 확인하는 흐름을 만들고자 했습니다.
해결 개요
구매자와 판매자를 별도 프로세스로 실행하고, 각자의 한도는 그 역할의 private state에만 둡니다. 협상 전에 한도의 commitment를 체인에 등록하고, 협상 메시지는 암호화 Relay로 주고받습니다. 외부 AI에는 한도와 실제 가격 오프셋을 뺀 입력으로 후보만 요청하고, 로컬 guard가 허용한 후보만 씁니다. 합의 후에는 Buyer가 가격이 자기 최대 한도 이하임을, Seller가 같은 가격 commitment의 가격이 자기 최저 한도 이상임을 각각 증명합니다.
- 01한도 입력 · 키 교환각자 한도를 자기 private state에만 두고 임시 키를 교환한다.
- 02commitment 등록 · OPEN한도의 commitment를 체인에 등록하고 계약이 OPEN인지 확인한다.
- 03암호화 협상 · 로컬 정책AI 후보를 로컬 guard로 거르고, 암호화 Relay로 제안을 주고받는다.
- 04Buyer 승인 · Seller 확정 · SETTLEDBuyer는 P ≤ B를, Seller는 같은 가격으로 S ≤ P를 각자 증명한다.
- 05증빙 보관 · 세션 정리최종 opening만 암호화 보관하고, 중간 비밀은 정리한다.
정보 경계
| 정보 | Buyer · Seller | AI | Relay | 공개 원장 |
|---|---|---|---|---|
| 각자의 한도 | 자기 역할만 보관 | 미전송 | 미전송 | 미기록 |
| 제안·합의 가격 | 두 당사자가 처리 | 오프셋을 뺀 좌표만 | 암호문만 | 미기록 |
| 가격 오프셋·공유 키 | 양측이 로컬에서 도출 | 미전송 | 미전송 | 미기록 |
| commitment·계약 상태 | 확인 가능 | 없음 | 암호문 | 공개 |
발표용 데모에서는 단일 브라우저와 로컬 Controller가 신뢰 경계 안에 있습니다. Controller는 입력 한도를 평문으로 받고, 확정 가격도 화면 표시를 위해 받습니다. 그래서 "비밀이 공개 원장에 기록되지 않는다"고 말할 뿐, "비밀을 다루는 로컬 구성 요소가 없다"고 주장하지 않습니다.
구조
| 구성 요소 | 역할 |
|---|---|
| demo-web | Buyer·Seller·Observer 3패널, 역할별 입력과 실제 런타임 이벤트 표시 |
| demo-controller | 역할 프로세스 실행·IPC 전달·상태 조율 |
| buyer / seller-runtime | 자기 한도·협상 상태·키 관리, 제안·수락·종료, 계약 호출 |
| agent-core | AI 요청 생성, 후보 검증, PolicyGuard·StrategyGuard, fallback, 가격 좌표 변환 |
| room-relay | Buyer·Seller가 직접 연결하는 독립 TCP 중계, 암호문 전달 |
| negotiation-contract | commitment·역할 권한·가격 조건·상태 전이를 검증하는 Compact 계약 |
| midnight-adapter | 지갑·DUST 준비, private state, proof provider, 계약 호출·Indexer 조회 |
| observer-runtime | Indexer에서 공개 계약 상태를 확인해 이벤트 생성 |
| evidence | 최종 증빙 암호화·복호화·체인 대조·정리·복구 |
내가 맡은 부분
혼자서 정보 경계 설계부터 계약·런타임·실행·검증까지 연결했습니다. 아래 설계 결정은 문서와 커밋에 남은 선택과 수정 이력만 적었습니다.
사전 한도 고정과 비공개 가격 검증 계약
한도 원문 대신 거래 ID·역할·공개키·금액·난수를 묶은 commitment를 등록하고, 합의 시 같은 한도로 가격 조건을 다시 검증하는 Compact 계약을 구현했습니다.
이전 버전은 최종 가격을 공개했지만, 9월 23일 finalPrice를 없애고 priceCommitment만 남겼습니다. Buyer 한도를 B, Seller 한도를 S, 합의 가격을 P라고 하면, Buyer는 자기 commitment를 열어 P ≤ B를, Seller는 같은 가격 commitment를 열어 S ≤ P를 증명합니다. 두 전이를 모두 거쳐 SETTLED가 되면 S ≤ P ≤ B가 성립합니다.
const maxPrice = buyerMaxPrice();
const limitRandomness = buyerLimitRandomness();
const price = agreedPrice();
const priceRandomnessValue = priceRandomness();
assert(
limitCommitment(pad(32, "negotiation:buyer:"), buyerKey, maxPrice, limitRandomness) == buyerCommitment,
"buyer limit commitment mismatch"
);
assert(price <= maxPrice, "price exceeds buyer maximum");
priceCommitment = disclose(priceCommitmentFor(price, priceRandomnessValue));
status = DealStatus.AUTHORIZED;
}"조건에 맞는 가격"과 "협상에서 실제로 합의한 가격"은 다릅니다. 계약은 협상 메시지를 보지 못하므로, Seller 런타임이 Buyer가 보낸 가격 opening을 자기가 기록한 합의 가격과 비교하고 다르면 취소합니다. 또 초기 joinDeal은 Seller 신원을 고정하지 않아 제3자가 자리를 차지할 수 있었는데, Seller 공개키를 배포 시 고정하고 비밀키 소유 검사를 추가해 막았습니다.
AI 후보 생성과 로컬 정책의 분리
AI가 최대 다섯 개의 제안·수락 후보를 만들고, 역할별 PolicyGuard·StrategyGuard가 허용한 후보만 고르는 경로를 구현했습니다. 모델에는 역할·상품 코드·라운드·기준가격·정규화한 현재 제안만 보냅니다.
한도를 프롬프트에 넣고 지켜달라고 요청하는 대신, 모델 밖에서 한도를 검사했습니다. API 실패나 허용되지 않는 후보가 반복되면 로컬 결정론적 fallback으로 진행합니다. 요청은 strict Structured Outputs와 stateless 방식으로 구성했습니다.
const providerContext: PublicNegotiationContext = {
role: context.role,
productCode: context.productCode,
round: context.round,
...(context.publicReferencePrice === undefined
? {}
: { publicReferencePrice: context.publicReferencePrice }),
...(context.currentOffer === undefined
? {}
: {
currentOffer: {
maker: context.currentOffer.maker,
price: context.currentOffer.price,
},
}),
};
const candidates = validateCandidates(
await input.provider.generateCandidates(providerContext),
);AI 오류가 협상 전체를 멈추거나, guard의 거절 이유가 외부 모델로 새는 것을 막아야 했습니다. 같은 허용 입력으로 최대 세 번 다시 요청하고, 실패 후보와 거절 사유는 외부 로그나 Relay로 내보내지 않습니다.
협상 규칙이 만드는 정보 추론 막기
처음 fallback은 한도의 비율로 제안을 만들어서, 규칙을 알면 제안 가격으로 한도를 역산할 수 있었습니다. 최대 한도 90,000,000원의 첫 제안이 81,000,000원이면 한도를 90,000,000~90,000,277원으로 좁힐 수 있었습니다.
9월 23일 한도와 무관한 공개 사다리로 바꿨습니다. 그런데 두 사다리가 늘 공개 기준가에서 만나면, 원장에 가격이 없어도 SETTLED만으로 최종가를 알 수 있었습니다. 그래서 9월 26일 양측이 공유 키에서 각자 같은 값을 도출하는 거래별 비공개 오프셋으로 두 사다리를 함께 옮겼습니다.
const bytes = Buffer.from(
hkdfSync(
"sha256",
input.sharedKey,
Buffer.from("midnight-private-price-offset-v1", "utf8"),
Buffer.from(
JSON.stringify([
input.sessionId,
input.productCode,
input.publicReferencePrice,
attempt,
]),
"utf8",
),
32,
),
);
const sample = BigInt(`0x${bytes.toString("hex")}`);
bytes.fill(0);
if (sample < ceiling) {AI에는 실제 가격에서 오프셋을 뺀 좌표를 보내고, 후보를 받으면 오프셋을 더해 실제 가격으로 되돌린 뒤 실제 한도로 다시 검사합니다. 단순 나머지 연산의 편향을 피하려고 rejection sampling을 썼습니다. 다만 수락·중단 시점에서 생기는 추론은 남아 있어, 완전히 숨겨진다고 주장하지 않습니다.
역할별 런타임과 암호화 협상 프로토콜
Buyer·Seller·Observer를 별도 Node.js 프로세스로, Room Relay를 네 번째 독립 프로세스로 구현했습니다. Buyer·Seller는 임시 X25519 키 교환과 HKDF-SHA-256으로 세션 키를 만들고 AES-256-GCM 패킷을 주고받습니다.
Controller가 모든 협상 메시지를 중계·해석하지 않도록, 화면 조율 경로와 당사자 간 암호화 경로를 나눴습니다. 이벤트에는 ROLE_LOCAL, PARTICIPANTS, PUBLIC 범위를 두어 공개 이벤트에 금액이 섞이지 않게 형식으로 막았습니다.
- 양측이 같은 상품 코드로 방에 들어가 임시 키를 교환하고 한도·commitment를 준비한다.
- Seller가 공개키를 암호화 채널로 전달하고, Buyer가 그 키와 자기 commitment를 고정해 계약을 배포한다.
- Seller가
joinDeal을 실행하고, Observer가 체인에서OPEN을 확인한 뒤 협상을 시작한다. - AI 후보·guard·fallback으로 최대 10라운드 안에 제안과 수락을 주고받는다.
- 성사되면 Buyer가 승인 증명을 내고 가격·난수를 암호화 채널로 보낸다. Seller는 합의가와 대조한 뒤 확정한다. 결렬되면 취소한다.
- 체인 상태를 확인한 뒤 각자 증빙을 보관하고 세션을 정리한다.
export const encryptRelayPayload = (input: {
sharedKey: Buffer;
metadata: RelayMetadata;
payload: unknown;
}): RelayPacket => {
if (input.sharedKey.length !== 32) {
throw new Error("relay shared key must contain 32 bytes");
}
const nonce = randomBytes(12);
const cipher = createCipheriv("aes-256-gcm", input.sharedKey, nonce);
cipher.setAAD(relayAssociatedData(input.metadata));
const ciphertext = Buffer.concat([
cipher.update(JSON.stringify(input.payload), "utf8"),
cipher.final(),
]);암호화만으로는 다른 방의 패킷이나 재전송을 막을 수 없습니다. 방·발신 역할·수신 역할·순번을 AAD에 묶고, 순번과 nonce 재사용 검사를 더했습니다.
ZK 증명부터 체인 관찰까지 연결
컴파일된 계약 바인딩, witness, 역할별 private state, 지갑·DUST, proof provider, Node·Indexer를 잇는 Midnight adapter를 구현했습니다. Buyer·Seller는 각자 전용 proof server를 쓰고, Observer는 공개 상태만 봅니다.
컴파일된 회로 → 역할별 witness로 비밀값 공급 → 로컬 proof server에서 증명 생성 → 지갑으로 거래 제출 → 체인이 증명·상태 전이 검증 → Indexer로 상태 확인. 중앙 서버가 두 한도를 모아 비교하는 방식이 아닙니다.
컴파일되는 것과 로컬 체인에서 지갑·DUST·증명·Indexer까지 실제로 도는 것은 달랐습니다. DUST 등록 오류에 제한된 재시도를 두고, 지갑 초기화 실패가 프로세스를 죽이던 경로를 고쳤습니다. 계약 호출이 끝났다는 알림만으로 화면을 바꾸지 않고, Observer가 체인에서 확인해야 표시하도록 했습니다.
최종 증빙 보관과 안전한 세션 정리
최종 합의 가격·가격 난수·계약 주소·거래 ID를 암호화 보관하고, 앱을 끈 뒤에도 복호화해 체인의 commitment와 대조하는 evidence 패키지와 CLI를 구현했습니다. 한도·지갑 seed·비밀키는 증빙에 넣지 않습니다.
전부 바로 지우면 나중에 합의를 확인할 수 없고, 전부 남기면 비밀의 노출 면적이 커집니다. 그래서 최종 opening만 암호화 보관하고, 체인 확정과 증빙 검증을 마친 뒤에만 중간 비밀을 정리합니다.
if (record.phase === "CLEANED") return { kind: "ALREADY_CLEANED" };
if (record.contractAddress === undefined) return { kind: "KEPT", reason: "NO_CONTRACT" };
const contractAddress = record.contractAddress;
// Nothing is erased for a session that cannot prove which deal it is.
if (record.dealId === undefined || record.network !== input.network) {
return { kind: "KEPT", reason: "IDENTITY_MISMATCH" };
}
const dealId = record.dealId;
// The chain decides the outcome. A caller's claim is never enough to treat
// an open or authorized deal as final.
let state: PublicDealState;
try {
state = await input.readDealState(contractAddress);
} catch {
return { kind: "KEPT", reason: "CHAIN_UNAVAILABLE" };
}같은 키로 암호화한 다른 거래의 정상 증빙을 넣으면 엉뚱한 세션을 지우는 경로가 있었습니다. 역할·네트워크·계약 주소·거래 ID가 모두 현재 세션과 맞을 때만 정리하도록 고쳤고, 체인 조회가 실패하거나 거래가 확정되지 않았으면 복구 데이터를 남깁니다.
결과
| 증거 | 확인된 결과 |
|---|---|
| 데모 영상 (54초) | 성공·결렬, 양측 가격 일치, Observer에 금액 비공개, 증빙 보관·정리. 대기 구간은 20배속 |
| 자동 테스트 | 루트 74/74 · 웹 8/8 · typecheck 통과 |
| 로컬 체인 | OPEN → AUTHORIZED → SETTLED, 양측 101,333,250 KRW 일치, 증빙 복원 확인 |
| 공개 데이터 검사 | 배포 1건·호출 3건의 raw와 상태에서 가격·한도 인코딩 미검출 |
| 실패 경로 | Buyer 90,000,000 / Seller 95,000,000 → CANCELLED, 금액 미공개 |
| 재현성 | 새 클론에서 설치·테스트·로컬 체인·증빙 확인 |
공개 데이터 검사는 해당 실행에 대한 확인이며, 모든 누출 경로에 대한 수학적 증명이나 외부 보안 감사가 아닙니다.
한계와 다음 단계
- 신뢰 경계: 지금은 한 화면과 Controller가 양측 입력을 다룹니다. 독립 Buyer·Seller 클라이언트와 조직별 키 연동이 다음 과제입니다.
- 남는 추론: 통신 시점, 수락·중단, 공개된 가격 규칙의 범위에서 추론이 가능합니다.
- 협상 완전성: 한도가 겹쳐도 사다리·라운드 제한 때문에 결렬될 수 있습니다.
CANCELLED는 한도가 겹치지 않는다는 증명이 아닙니다. - 조건의 진위: commitment는 입력한 한도를 고정할 뿐, 회사의 실제 승인 금액이나 원가가 맞는지는 증명하지 않습니다.
- 삭제의 의미: 정리는 논리 삭제라, 디스크 잔여나 백업의 영구 소거까지 보장하지 않습니다.
- 배포: 공개 네트워크 배포와 악성 참여자·중계자 위협 검토가 남아 있습니다.
배운 점
- 비밀값을 보내지 않는 것만으로 프라이버시가 완성되지는 않는다. 데이터 필드뿐 아니라 결정 규칙과 상태 전이가 만드는 추론도 검토해야 했다.
- AI의 역할과 보장 책임을 분리해야 한다. 모델은 후보를 만들고, 로컬 guard는 실행을 제한하고, 계약은 조건을 검증한다. 경계를 나누니 모델 장애와 안전 조건을 따로 다룰 수 있었다.
- 검증 가능성과 데이터 최소 보관을 함께 설계해야 한다. 증빙이 유효한지뿐 아니라 "지금 이 거래의 증빙인지"를 확인해야 잘못된 삭제를 막을 수 있었다.
주요 결정
| 결정 | 근거 |
|---|---|
| AI에 한도를 주지 않고 로컬 guard에서 검증 | agent-core, 역할별 런타임 |
| 협상 전 한도를 고정하고 체결 시 재검증 | negotiation.compact |
| 최종 가격 공개를 없애고 같은 commitment로 양측 조건 연결 | df9b157 |
| 한도 기반 제안의 역산 경로를 공개 사다리로 교체 | dc207eb |
| 기준가로 최종가를 알아내는 경로를 거래별 오프셋으로 보완 | bb9490f |
| Seller 자리 탈취를 막기 위해 배포 시 역할 키 고정 | 999c1af |
| 체인 확인·증빙 검증 뒤에만 중간 비밀 정리 | cb4c7fb, 69e80b2 |
Midnight·Compact SDK와 proof server, 암호 라이브러리, OpenAI 모델은 외부 기반으로 사용했습니다. 블록체인 노드, 증명 알고리즘, 모델 자체를 새로 만든 것은 아닙니다.
개발 기록
아직 개발 기록이 없습니다.