ZK-App Client Implementation with Semaphore
Author: ChiHaoLu(chihaolu.eth) @ swfLAB
本文僅供各為作為筆記服用,不具教學功能。適合已經基本理解何謂 ZKP 且會撰寫 Circom、Solidity 等語言的人閱讀。
Semaphore
Quick Look the Core Design
本篇文章主要透過 Semaphore 搭建 ZKP 身分應用,並介紹如何撰寫銜接電路以及合約的前端,這樣用戶們就不用自己產 Proof 與智能合約互動了,可以透過我們(項目方、Operator)提供的前端(客戶端)操作來證明即可。
Semaphore 是一個去中心化的匿名登入與身分證明系統。想法是在合約中有一個 Merkle tree 的 membership proof,可以不公開密碼也能證明自己是某個群體中(某顆 merkle tree)的成員(merkle tree 的其中一個 leaf)。
Semaphore 可以讓用戶在智能合約上註冊一個 Idendity(身分),並且廣播(Broadcast)他們的訊號(Signal),特點在於:
- 可以匿名的證明他們的身分存在某一個群體之中
- 公開地儲存(Broadcast)任意字串(Signal)在合約之中,這之中有類似於 nonce 的 External Nullifier 存在,如果在 External Nullifier 的情況下,有對同一個字串做 double-signalling 那就是非法的
實際上有 nullfier 和 path index 就已經足夠證明了,還需要 External Nullifier 是因為其作為 Nonce 作用,能讓一個 Identity 可以多次的在同一輪(Merkle Tree)中對同一個 Signal 進行廣播。
在 External Nullifier 不同的情況下,就會生成不同的nullifier hash。
當使用者註冊了他們的 identity,Semaphore 會產生一組 EdDSA 公私鑰對 pubKey/privKeypubKey/privKey,並且將 publickeypublickey 和兩個隨機秘密值哈希之後送去給合約,儲存在 Merkle Tree 中(就是加入一個 Group)。這個 Hash 就是 identityCommitmentidentityCommitment,而兩個隨機秘密值則為 identityNullifieridentityNullifier 和 identityTrapdooridentityTrapdoor。
接下來使用者就能用他們的 privKeyprivKey 來計算公鑰 pubKeypubKey,再用 pubKeypubKey 產生該 merkle tree 的 merkle proof。
在此 circuit 中只要公開該 merkle tree 的 root,其他使用者即可知道此使用者是這個 group 的成員
ZKP 的部分則是在驗證公私鑰對以及驗證公鑰是某個 merkle tree 的 leaf。用戶能夠在 Semaphore 提供的前端(client 端、鏈下)產生他們在某個 group 中的 proof。將 proof 送到合約中時就可以驗證這個 proof 是否 valid。
零知識證明可以讓用戶證明他們知曉某個已定義的資訊,卻不用把資訊本身揭露。
Glossary
const private_key = crypto.randomBytes(32).toString('hex');
const prvKey = Buffer.from(private_key, 'hex');
const pubKey = eddsa.prv2pub(prvKey);
const identity_nullifier = '0x' + crypto.randomBytes(31).toString('hex');
const identity_trapdoor = '0x' + crypto.randomBytes(31).toString('hex');
const identity_commitment = pedersenHash([
bigInt(circomlib.babyJub.mulPointEscalar(pubKey, 8)[0]),
bigInt(identity_nullifier),
bigInt(identity_trapdoor)
]);
每個 Identity 由兩個隱私部分組成(都是隨機產生的,且不紀錄在以太坊的智慧合約中):
- 31 Bytes 的 nullifier
- 31 Bytes 的 trapdoor
每個 Identity 會對應兩個公開資訊:
- Commitment 會存在合約中
- nullifier_hash:用來證明某個 Identity 對應的 commitment 存在 merkle tree 上。
每個 Identity 主要的兩個 Operations 分別為 Enroll(Deposit) 和 Boardcast(Withdral)。
當我們想要搭建一個 Dapp 在 Semaphore 上的時候,要在 Merkle Tree 中註冊一個 Identity 其實同時可以做為用戶註冊我們 Dapp 的行為,舉例來說在 DeFi 應用上可能是 Staking 或者 Depositing。
而當用戶想要領取金錢時(證明他在某個 Merkle Tree 中),也就是對應 Boardcast。
Deposit/Enroll
insertIdendity()
可以讓用戶的 Identity Commitment 被 insert 進 Merkle Tree 之中:
function insertIdentity(uint256 identity_commitment) public {
insert(id_tree_index, identity_commitment);
uint256 root = tree_roots[id_tree_index];
root_history[root] = true;
}
Identity 對應的 commitment 會新增到一個 merkle 樹(Group)上,同時新的 merkle 樹根會記錄在 root_history 的 mapping 中。
Withdral/Boardcast
當用戶已經在合約中有他們的 Identity 就可以發訊號(Signal)了。要發訊號時必須先提供其 Identity 在 Tree 上的證明(能計算出commitment):
function broadcastSignal(
bytes memory signal,
uint[2] memory a,
uint[2][2] memory b,
uint[2] memory c,
uint[4] memory input // (root, nullifiers_hash, signal_hash, external_nullifier)
) public isValidSignalAndProof(signal, a, b, c, input)
{
uint nullifiers_hash = input[1];
signals[current_signal_index++] = signal;
nullifier_hash_history[nullifiers_hash] = true;
emit SignalBroadcast(signal, nullifiers_hash, input[3]);
}
signal
為用戶想要傳送的資訊(字串)a, b, c
為 ZKP 的 proofinput
是 ZKP 的 circuit public input。通常這個包括輸入包含 Merkle Root、nullifier hash、signal hash 和 External Nullifier。
進到 boardcastSignal()
前最先的驗證為 isValidSignalAndProof
,會依序驗證以下內容:
- 廣播前的 Merkle Root 在 Contract 中被確實存著
- 這個 External Nullifier 沒有被使用過(為了避免 double-signaling)
- Signal Hash 是合法的
- External Nullifier 是正確的
- Proof 和 Input 送到 verifier 驗證通過
在 Proof 通過驗證之後,Signal 就會被記錄下來(同時對應的 nullifier hash 也會被記錄下來),如果 External Nullifier不變的話,所有 Identity 只能傳送一次 Signal。
System Design and Client
Design View
這個部分會使用 semaphore-protocol/boilerplate 作為範例使用,基本上最重要的內容就在 ./apps/web-app/src/pages 之中的三個檔案。
index.tsx
: 生成 New Identity,也就是產生新的三個 Trapdoor(private, known only by user)、Nullifier(private, known only by user)、Commitment(public)groups.tsx
:加入 Group,也就是加入 Merkle Treeproofs.tsx
:廣播訊號,同時進行驗證
基本上需要用到的物件都來自於 Semaphore 官方套件:
import { Group } from "@semaphore-protocol/group"
import { Identity } from "@semaphore-protocol/identity"
import { generateProof, packToSolidityProof } from "@semaphore-protocol/proof"
主要是使用 subgraph
來聆聽當前區塊鏈的資訊(例如去 Fetch 合約中有的 Group 之類的)。
New Identity(index.tsx
)
useEffect(() => {
const identityString = localStorage.getItem("identity")
if (identityString) {
const identity = new Identity(identityString)
setIdentity(identity)
setLogs("Your Semaphore identity was retrieved from the browser cache 👌🏽")
} else {
setLogs("Create your Semaphore identity 👆🏽")
}
}, [])
const createIdentity = useCallback(async () => {
const identity = new Identity()
setIdentity(identity)
localStorage.setItem("identity", identity.toString())
setLogs("Your new Semaphore identity was just created 🎉")
}, [])
Enter Group(groups.tsx
)
const joinGroup = useCallback(async () => {
if (!_identity) {
return
}
const username = window.prompt("Please enter your username:")
if (username) {
setLoading.on()
setLogs(`Joining the Greeter group...`)
const { status } = await fetch("api/join", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
identityCommitment: _identity.commitment.toString(),
username
})
})
if (status === 200) {
addUser({ identityCommitment: _identity.commitment.toString(), username })
setLogs(`You joined the Greeter group event 🎉 Greet anonymously!`)
} else {
setLogs("Some error occurred, please try again!")
}
setLoading.off()
}
}, [_identity])
Broadcast Signal(proofs.tsx
)
const sendFeedback = useCallback(async () => {
if (!_identity) {
return
}
const options = ["zkConditionerRound1+1", "zkConditionerRound1-1"]
const feedback = prompt(
'Please vote with below options without quotes: \n1. "zkConditionerRound1+1" \n2. "zkConditionerRound1-1"'
)
if (feedback === null) {
alert("You can input null opinion!")
return
} else {
if (!options.includes(feedback?.toString())) {
alert('Your opinion is not "zkConditionerRound1+1" or "zkConditionerRound1-1"!')
return
}
}
if (feedback) {
setLoading.on()
setLogs(`Posting your anonymous vote...`)
try {
const group = new Group()
const feedbackHash = solidityKeccak256(["string"], [feedback])
const subgraph = new Subgraph("goerli")
const { members } = await subgraph.getGroup(env.GROUP_ID, { members: true })
group.addMembers(members)
const { proof, publicSignals } = await generateProof(_identity, group, env.GROUP_ID, feedbackHash)
const solidityProof = packToSolidityProof(proof)
const { status } = await fetch("api/feedback", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
feedback,
merkleRoot: publicSignals.merkleRoot,
nullifierHash: publicSignals.nullifierHash,
solidityProof
})
})
if (status === 200) {
addFeedback(feedback)
setLogs(`Your vote was posted 🎉`)
} else {
setLogs("Some error occurred, please try again!")
}
} catch (error) {
console.error(error)
setLogs("Some error occurred, please try again!")
} finally {
setLoading.off()
}
}
}, [_identity])