@zkclaw/credentials 1.0.0 → 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +5 -7
- package/src/circuit/Nargo.toml +9 -0
- package/src/circuit/src/main.nr +52 -0
- package/src/circuit/target/vk +0 -0
- package/src/circuit/target/vkey +0 -0
- package/src/index.ts +16 -0
- package/src/noir-lib/Nargo.toml +9 -0
- package/src/noir-lib/src/bytes/mod.nr +16 -0
- package/src/noir-lib/src/ecrecover/mod.nr +40 -0
- package/src/noir-lib/src/ecrecover/secp256k1.nr +87 -0
- package/src/noir-lib/src/lib.nr +4 -0
- package/src/noir-lib/src/proof/mod.nr +768 -0
- package/src/noir-lib/src/rlp/mod.nr +128 -0
- package/src/utils/circuit.ts +74 -0
- package/src/utils/index.ts +59 -0
- package/src/verifier.test.ts +163 -0
- package/src/verifier.ts +227 -0
- package/dist/index.js +0 -479
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
use dep::std::wrapping_sub;
|
|
2
|
+
|
|
3
|
+
pub global RLP_DATA_TYPE_STRING = 0;
|
|
4
|
+
pub global RLP_DATA_TYPE_LIST = 1;
|
|
5
|
+
|
|
6
|
+
pub struct RlpFragment {
|
|
7
|
+
pub offset: u32,
|
|
8
|
+
pub length: u32,
|
|
9
|
+
pub data_type: u32,
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
pub unconstrained fn decode_rlp_list_fragments<let NODE_LEN: u32, let MAX_FIELDS: u32>(
|
|
13
|
+
rlp_header: RlpFragment,
|
|
14
|
+
node: [u8; NODE_LEN],
|
|
15
|
+
) -> BoundedVec<RlpFragment, MAX_FIELDS> {
|
|
16
|
+
let node_len = rlp_header.length + rlp_header.offset;
|
|
17
|
+
let mut rlp_list = BoundedVec::new();
|
|
18
|
+
let mut curr_offset = rlp_header.offset;
|
|
19
|
+
for _ in 0..MAX_FIELDS {
|
|
20
|
+
if (curr_offset < node_len) {
|
|
21
|
+
let field_prefix = node[curr_offset];
|
|
22
|
+
|
|
23
|
+
let field_offset = if field_prefix < 0x80 { 0 } else { 1 };
|
|
24
|
+
let field_length = if field_prefix < 0x80 {
|
|
25
|
+
1
|
|
26
|
+
} else {
|
|
27
|
+
wrapping_sub(field_prefix as u32, 0x80)
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
rlp_list.push(
|
|
31
|
+
RlpFragment {
|
|
32
|
+
offset: curr_offset + field_offset,
|
|
33
|
+
length: field_length,
|
|
34
|
+
data_type: RLP_DATA_TYPE_STRING,
|
|
35
|
+
},
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
curr_offset += field_length + field_offset;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
rlp_list
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
pub unconstrained fn decode_rlp_header<let NODE_LEN: u32>(node: [u8; NODE_LEN]) -> RlpFragment {
|
|
46
|
+
let (prefix, data) = node.as_slice().pop_front();
|
|
47
|
+
|
|
48
|
+
if (prefix < 0x80) {
|
|
49
|
+
// 1 byte
|
|
50
|
+
RlpFragment { offset: 0 as u32, length: 1 as u32, data_type: RLP_DATA_TYPE_STRING }
|
|
51
|
+
} else if (prefix < 0xb8) {
|
|
52
|
+
// 0-55 byte string
|
|
53
|
+
RlpFragment {
|
|
54
|
+
offset: 1,
|
|
55
|
+
length: wrapping_sub(prefix, 0x80) as u32,
|
|
56
|
+
data_type: RLP_DATA_TYPE_STRING,
|
|
57
|
+
}
|
|
58
|
+
} else if (prefix < 0xc0) {
|
|
59
|
+
// > 55 byte string
|
|
60
|
+
RlpFragment {
|
|
61
|
+
offset: wrapping_sub(1 + prefix, 0xb7) as u32,
|
|
62
|
+
length: extract_payload_len(data, wrapping_sub(prefix, 0xb7) as u32),
|
|
63
|
+
data_type: RLP_DATA_TYPE_STRING,
|
|
64
|
+
}
|
|
65
|
+
} else if (prefix < 0xf8) {
|
|
66
|
+
// 0-55 byte array
|
|
67
|
+
RlpFragment {
|
|
68
|
+
offset: 1,
|
|
69
|
+
length: wrapping_sub(prefix, 0xc0) as u32,
|
|
70
|
+
data_type: RLP_DATA_TYPE_LIST,
|
|
71
|
+
}
|
|
72
|
+
} else {
|
|
73
|
+
// > 55 byte array
|
|
74
|
+
RlpFragment {
|
|
75
|
+
offset: wrapping_sub(1 + prefix, 0xf7) as u32,
|
|
76
|
+
length: extract_payload_len(data, wrapping_sub(prefix, 0xf7) as u32),
|
|
77
|
+
data_type: RLP_DATA_TYPE_LIST,
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
fn extract_payload_len(data: [u8], len: u32) -> u32 {
|
|
83
|
+
let data_len = data.len();
|
|
84
|
+
let mut node_len = 0;
|
|
85
|
+
for i in 0..2 {
|
|
86
|
+
if (i < len & i < data_len) {
|
|
87
|
+
node_len = data[i] as u32 + node_len * 256;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
node_len
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
pub fn encode_rlp_string<let N: u32>(data: [u8; N]) -> [u8; N] {
|
|
95
|
+
let length = data.len();
|
|
96
|
+
let mut result = [0; N];
|
|
97
|
+
|
|
98
|
+
// Find first non-zero byte using for loop
|
|
99
|
+
let mut start_idx = 0;
|
|
100
|
+
for i in 0..N {
|
|
101
|
+
if (start_idx == 0) & (data[i] != 0) {
|
|
102
|
+
start_idx = i;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// If all zeros, return single zero byte
|
|
107
|
+
if (start_idx == 0) & (data[0] == 0) {
|
|
108
|
+
result[0] = 0x80;
|
|
109
|
+
result
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
let actual_length = length - start_idx;
|
|
113
|
+
|
|
114
|
+
if (actual_length == 1) & (data[start_idx] < 0x80) {
|
|
115
|
+
// Single byte < 0x80
|
|
116
|
+
result[0] = data[start_idx];
|
|
117
|
+
} else {
|
|
118
|
+
// 0-55 bytes string
|
|
119
|
+
result[0] = (0x80 + actual_length) as u8;
|
|
120
|
+
for i in 0..N {
|
|
121
|
+
if i < actual_length {
|
|
122
|
+
result[i + 1] = data[start_idx + i];
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
result
|
|
128
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import type { CompiledCircuit, Noir } from '@noir-lang/noir_js'
|
|
2
|
+
import type { UltraHonkBackend, BarretenbergVerifier, ProofData } from '@aztec/bb.js'
|
|
3
|
+
|
|
4
|
+
type ProverModules = {
|
|
5
|
+
Noir: typeof Noir
|
|
6
|
+
UltraHonkBackend: typeof UltraHonkBackend
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
type VerifierModules = {
|
|
10
|
+
BarretenbergVerifier: typeof BarretenbergVerifier
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export abstract class Circuit {
|
|
14
|
+
private proverPromise: Promise<ProverModules> | null = null
|
|
15
|
+
private verifierPromise: Promise<VerifierModules> | null = null
|
|
16
|
+
|
|
17
|
+
private circuit: CompiledCircuit
|
|
18
|
+
private vkey: Uint8Array
|
|
19
|
+
|
|
20
|
+
constructor(circuit: unknown, vkey: unknown) {
|
|
21
|
+
this.circuit = circuit as CompiledCircuit
|
|
22
|
+
// Convert vkey from JSON array to Uint8Array if needed
|
|
23
|
+
this.vkey = Array.isArray(vkey) ? new Uint8Array(vkey) : (vkey as Uint8Array)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async initProver(): Promise<ProverModules> {
|
|
27
|
+
if (!this.proverPromise) {
|
|
28
|
+
this.proverPromise = (async () => {
|
|
29
|
+
const [{ Noir }, { UltraHonkBackend }] = await Promise.all([
|
|
30
|
+
import('@noir-lang/noir_js'),
|
|
31
|
+
import('@aztec/bb.js'),
|
|
32
|
+
])
|
|
33
|
+
return {
|
|
34
|
+
Noir,
|
|
35
|
+
UltraHonkBackend,
|
|
36
|
+
}
|
|
37
|
+
})()
|
|
38
|
+
}
|
|
39
|
+
return this.proverPromise
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async initVerifier(): Promise<VerifierModules> {
|
|
43
|
+
if (!this.verifierPromise) {
|
|
44
|
+
this.verifierPromise = (async () => {
|
|
45
|
+
const { BarretenbergVerifier } = await import('@aztec/bb.js')
|
|
46
|
+
return { BarretenbergVerifier }
|
|
47
|
+
})()
|
|
48
|
+
}
|
|
49
|
+
return this.verifierPromise
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async verify(proofData: ProofData) {
|
|
53
|
+
const { BarretenbergVerifier } = await this.initVerifier()
|
|
54
|
+
|
|
55
|
+
const verifier = new BarretenbergVerifier({ crsPath: process.env.TEMP_DIR })
|
|
56
|
+
const result = await verifier.verifyUltraHonkProof(proofData, this.vkey)
|
|
57
|
+
|
|
58
|
+
return result
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
62
|
+
async generate(input: any) {
|
|
63
|
+
const { Noir, UltraHonkBackend } = await this.initProver()
|
|
64
|
+
|
|
65
|
+
const backend = new UltraHonkBackend(this.circuit.bytecode)
|
|
66
|
+
const noir = new Noir(this.circuit)
|
|
67
|
+
|
|
68
|
+
const { witness } = await noir.execute(input)
|
|
69
|
+
|
|
70
|
+
return await backend.generateProof(witness)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
abstract parseData(publicInputs: string[]): unknown
|
|
74
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { recoverPublicKey } from 'viem'
|
|
2
|
+
|
|
3
|
+
export async function getPublicKey(signature: `0x${string}`, messageHash: `0x${string}`) {
|
|
4
|
+
const pubKey = await recoverPublicKey({
|
|
5
|
+
hash: messageHash,
|
|
6
|
+
signature,
|
|
7
|
+
})
|
|
8
|
+
const pubKeyX = pubKey.slice(4, 68)
|
|
9
|
+
const pubKeyY = pubKey.slice(68)
|
|
10
|
+
|
|
11
|
+
return { pubKeyX, pubKeyY }
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function formatArray(
|
|
15
|
+
arr: string[],
|
|
16
|
+
formatFn: (item: string) => string[],
|
|
17
|
+
{ length = 7, pad = 'end' }: { length?: number; pad?: 'start' | 'end' } = {}
|
|
18
|
+
) {
|
|
19
|
+
const result: string[][] = []
|
|
20
|
+
for (const item of arr) {
|
|
21
|
+
result.push(formatFn(item))
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
while (result.length < length) {
|
|
25
|
+
if (pad === 'start') {
|
|
26
|
+
result.unshift(formatFn('0x00'))
|
|
27
|
+
} else {
|
|
28
|
+
result.push(formatFn('0x00'))
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return result
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function formatHexArray(
|
|
36
|
+
hex: string,
|
|
37
|
+
{
|
|
38
|
+
chunkSize = 2,
|
|
39
|
+
length = 32,
|
|
40
|
+
pad = 'left',
|
|
41
|
+
}: { chunkSize?: number; length?: number; pad?: 'left' | 'right' } = {}
|
|
42
|
+
) {
|
|
43
|
+
const str = hex.replace('0x', '')
|
|
44
|
+
|
|
45
|
+
const arr: string[] = []
|
|
46
|
+
for (let i = 0; i < str.length; i += chunkSize) {
|
|
47
|
+
arr.push(`0x${str.slice(i, i + chunkSize)}`)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
while (arr.length < length) {
|
|
51
|
+
if (pad === 'left') {
|
|
52
|
+
arr.unshift('0x00')
|
|
53
|
+
} else {
|
|
54
|
+
arr.push('0x00')
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return arr.slice(0, length)
|
|
59
|
+
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { describe, test, expect } from 'bun:test'
|
|
2
|
+
import { hashMessage } from 'viem'
|
|
3
|
+
import {
|
|
4
|
+
AnonBalanceVerifier,
|
|
5
|
+
getVerifier,
|
|
6
|
+
ANON_TOKEN,
|
|
7
|
+
BALANCE_THRESHOLDS,
|
|
8
|
+
} from './verifier'
|
|
9
|
+
|
|
10
|
+
describe('AnonBalanceVerifier - Unit Tests', () => {
|
|
11
|
+
test('should export correct token configuration', () => {
|
|
12
|
+
// $ZKCLAW token on Base chain
|
|
13
|
+
expect(ANON_TOKEN.address).toBe('0x000000000000000000000000000000000000dead')
|
|
14
|
+
expect(ANON_TOKEN.chainId).toBe(8453) // Base
|
|
15
|
+
expect(ANON_TOKEN.balanceSlot).toBe(0)
|
|
16
|
+
expect(ANON_TOKEN.decimals).toBe(18)
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
test('should export correct balance thresholds', () => {
|
|
20
|
+
// 5,000 tokens with 18 decimals
|
|
21
|
+
expect(BALANCE_THRESHOLDS.POST).toBe(BigInt('5000000000000000000000'))
|
|
22
|
+
// 2,000,000 tokens with 18 decimals
|
|
23
|
+
expect(BALANCE_THRESHOLDS.PROMOTE).toBe(BigInt('2000000000000000000000000'))
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
test('should return singleton verifier instance', () => {
|
|
27
|
+
const verifier1 = getVerifier()
|
|
28
|
+
const verifier2 = getVerifier()
|
|
29
|
+
expect(verifier1).toBe(verifier2)
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
test('canPost returns correct values', () => {
|
|
33
|
+
const verifier = new AnonBalanceVerifier()
|
|
34
|
+
|
|
35
|
+
// Below threshold
|
|
36
|
+
expect(verifier.canPost(BigInt('4999000000000000000000'))).toBe(false)
|
|
37
|
+
|
|
38
|
+
// At threshold
|
|
39
|
+
expect(verifier.canPost(BigInt('5000000000000000000000'))).toBe(true)
|
|
40
|
+
|
|
41
|
+
// Above threshold
|
|
42
|
+
expect(verifier.canPost(BigInt('10000000000000000000000'))).toBe(true)
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
test('canPromote returns correct values', () => {
|
|
46
|
+
const verifier = new AnonBalanceVerifier()
|
|
47
|
+
|
|
48
|
+
// Below threshold
|
|
49
|
+
expect(verifier.canPromote(BigInt('1999999000000000000000000'))).toBe(false)
|
|
50
|
+
|
|
51
|
+
// At threshold
|
|
52
|
+
expect(verifier.canPromote(BigInt('2000000000000000000000000'))).toBe(true)
|
|
53
|
+
|
|
54
|
+
// Above threshold
|
|
55
|
+
expect(verifier.canPromote(BigInt('3000000000000000000000000'))).toBe(true)
|
|
56
|
+
})
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
describe('AnonBalanceVerifier - Network Tests', () => {
|
|
60
|
+
test('buildInput fetches storage proof from Base', async () => {
|
|
61
|
+
const verifier = new AnonBalanceVerifier()
|
|
62
|
+
|
|
63
|
+
// Use an address that holds some $ANON (found via on-chain lookup)
|
|
64
|
+
const testAddress = '0x0000000000000000000000000000000000000001'
|
|
65
|
+
const result = await verifier.buildInput(testAddress, BigInt(0))
|
|
66
|
+
|
|
67
|
+
expect(result.input.chainId).toBe('0x2105') // 8453 in hex
|
|
68
|
+
expect(result.input.tokenAddress).toBe(ANON_TOKEN.address)
|
|
69
|
+
expect(result.input.storageHash).toBeDefined()
|
|
70
|
+
expect(result.input.storageHash).toMatch(/^0x[a-fA-F0-9]{64}$/)
|
|
71
|
+
expect(result.input.storageProof).toBeDefined()
|
|
72
|
+
expect(result.input.storageProof.length).toBeGreaterThan(0)
|
|
73
|
+
expect(result.message).toBeDefined()
|
|
74
|
+
|
|
75
|
+
// Verify the proof structure
|
|
76
|
+
const proof = result.input.storageProof[0]
|
|
77
|
+
expect(proof.proof.length).toBeGreaterThan(0)
|
|
78
|
+
console.log('Storage proof depth:', proof.proof.length)
|
|
79
|
+
console.log('Storage value:', proof.value.toString())
|
|
80
|
+
}, 30000)
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
describe('AnonBalanceVerifier - Proof Tests', () => {
|
|
84
|
+
// These tests require a valid signature from a wallet that holds $ANON
|
|
85
|
+
// To run: set TEST_SIGNATURE and TEST_ADDRESS environment variables
|
|
86
|
+
// The signature should be over the message 'test' (using hashMessage('test'))
|
|
87
|
+
//
|
|
88
|
+
// NOTE: The old test address 0xe4dd432fe405891ab0118760e3116e371188a1eb
|
|
89
|
+
// no longer holds $ANON tokens. You need to provide credentials from
|
|
90
|
+
// a wallet that currently holds tokens.
|
|
91
|
+
|
|
92
|
+
const testSignature = process.env.TEST_SIGNATURE
|
|
93
|
+
const testAddress = process.env.TEST_ADDRESS
|
|
94
|
+
|
|
95
|
+
const shouldRunProofTests = testSignature && testAddress
|
|
96
|
+
|
|
97
|
+
test.skipIf(!shouldRunProofTests)('generates and verifies proof end-to-end', async () => {
|
|
98
|
+
const verifier = new AnonBalanceVerifier()
|
|
99
|
+
|
|
100
|
+
// Build input with storage proof from chain
|
|
101
|
+
// Note: We use a fixed message 'test' to match the pre-generated signature
|
|
102
|
+
const { input } = await verifier.buildInput(testAddress!, BigInt(1))
|
|
103
|
+
|
|
104
|
+
// Check if the address actually has a balance
|
|
105
|
+
const storageValue = input.storageProof[0].value
|
|
106
|
+
if (storageValue === BigInt(0)) {
|
|
107
|
+
console.log('⚠️ Test address has 0 balance - skipping proof generation')
|
|
108
|
+
console.log(' To run this test, provide TEST_ADDRESS with $ANON tokens')
|
|
109
|
+
return
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
console.log('Balance:', storageValue.toString())
|
|
113
|
+
console.log('Generating proof...')
|
|
114
|
+
console.time('generateProof')
|
|
115
|
+
|
|
116
|
+
// Generate proof using the test signature (signed over 'test' message)
|
|
117
|
+
const proof = await verifier.generateProof({
|
|
118
|
+
...input,
|
|
119
|
+
signature: testSignature!,
|
|
120
|
+
messageHash: hashMessage('test'),
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
console.timeEnd('generateProof')
|
|
124
|
+
|
|
125
|
+
expect(proof.proof).toBeDefined()
|
|
126
|
+
expect(proof.proof.length).toBeGreaterThan(0)
|
|
127
|
+
expect(proof.publicInputs).toBeDefined()
|
|
128
|
+
|
|
129
|
+
console.log('Verifying proof...')
|
|
130
|
+
console.time('verifyProof')
|
|
131
|
+
|
|
132
|
+
// Verify the proof
|
|
133
|
+
const verified = await verifier.verifyProof(proof)
|
|
134
|
+
|
|
135
|
+
console.timeEnd('verifyProof')
|
|
136
|
+
|
|
137
|
+
expect(verified).toBe(true)
|
|
138
|
+
|
|
139
|
+
// Parse and validate the public data
|
|
140
|
+
const data = verifier.parseData(proof.publicInputs)
|
|
141
|
+
expect(data.chainId).toBe(ANON_TOKEN.chainId)
|
|
142
|
+
|
|
143
|
+
console.log('Proof verified! Data:', data)
|
|
144
|
+
}, 300000)
|
|
145
|
+
|
|
146
|
+
// If no test credentials, just log instructions
|
|
147
|
+
test.skipIf(shouldRunProofTests)('instructions for running proof tests', () => {
|
|
148
|
+
console.log(`
|
|
149
|
+
To run proof generation tests, you need a wallet that holds $ANON tokens.
|
|
150
|
+
|
|
151
|
+
1. Get the message to sign:
|
|
152
|
+
const verifier = new AnonBalanceVerifier()
|
|
153
|
+
const { message } = await verifier.buildInput(YOUR_ADDRESS, BigInt(1))
|
|
154
|
+
|
|
155
|
+
2. Sign the message with your wallet (e.g., using ethers or viem)
|
|
156
|
+
|
|
157
|
+
3. Run tests with environment variables:
|
|
158
|
+
TEST_ADDRESS=0x... TEST_SIGNATURE=0x... bun test
|
|
159
|
+
|
|
160
|
+
Note: The signature must be from the wallet at TEST_ADDRESS which must hold $ANON tokens.
|
|
161
|
+
`)
|
|
162
|
+
})
|
|
163
|
+
})
|
package/src/verifier.ts
ADDED
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type GetProofReturnType,
|
|
3
|
+
keccak256,
|
|
4
|
+
concat,
|
|
5
|
+
toHex,
|
|
6
|
+
pad,
|
|
7
|
+
createPublicClient,
|
|
8
|
+
http,
|
|
9
|
+
type PublicClient,
|
|
10
|
+
} from 'viem'
|
|
11
|
+
import { base } from 'viem/chains'
|
|
12
|
+
|
|
13
|
+
// Use premium RPC if available, fallback to public
|
|
14
|
+
const BASE_RPC_URL = process.env.BASE_RPC_URL || 'https://mainnet.base.org'
|
|
15
|
+
import { formatArray, formatHexArray, getPublicKey } from './utils'
|
|
16
|
+
import { Circuit } from './utils/circuit'
|
|
17
|
+
import circuit from './circuit/target/anon_balance.json'
|
|
18
|
+
import vkey from './circuit/target/vkey.json'
|
|
19
|
+
|
|
20
|
+
// $ZKCLAW token on Base (hardcoded for SDK bundle compatibility)
|
|
21
|
+
export const ZKCLAW_TOKEN = {
|
|
22
|
+
address: '0x8849386BCd0fA6B8fF484A4aAd9c374B2F7c4B07' as `0x${string}`,
|
|
23
|
+
chainId: 8453,
|
|
24
|
+
balanceSlot: 0, // Standard ERC20 balance slot
|
|
25
|
+
decimals: 18,
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Legacy alias for compatibility
|
|
29
|
+
export const ANON_TOKEN = ZKCLAW_TOKEN
|
|
30
|
+
|
|
31
|
+
// Balance thresholds (hardcoded for SDK bundle compatibility)
|
|
32
|
+
export const BALANCE_THRESHOLDS = {
|
|
33
|
+
POST: BigInt('50000') * BigInt(10 ** 18), // 50K tokens
|
|
34
|
+
PROMOTE: BigInt('20000000') * BigInt(10 ** 18), // 20M tokens
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export type ProofData = {
|
|
38
|
+
proof: number[]
|
|
39
|
+
publicInputs: string[]
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export type CredentialData = {
|
|
43
|
+
balance: string
|
|
44
|
+
chainId: number
|
|
45
|
+
blockNumber: string
|
|
46
|
+
tokenAddress: string
|
|
47
|
+
balanceSlot: string
|
|
48
|
+
storageHash: string
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export type BuildInputResult = {
|
|
52
|
+
input: {
|
|
53
|
+
storageHash: string
|
|
54
|
+
storageProof: GetProofReturnType['storageProof']
|
|
55
|
+
chainId: string
|
|
56
|
+
blockNumber: string
|
|
57
|
+
tokenAddress: string
|
|
58
|
+
balanceSlot: string
|
|
59
|
+
verifiedBalance: string
|
|
60
|
+
}
|
|
61
|
+
message: string
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export type GenerateProofInput = BuildInputResult['input'] & {
|
|
65
|
+
signature: string
|
|
66
|
+
messageHash: string
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export class AnonBalanceVerifier extends Circuit {
|
|
70
|
+
private client: PublicClient
|
|
71
|
+
|
|
72
|
+
constructor() {
|
|
73
|
+
super(circuit, vkey)
|
|
74
|
+
this.client = createPublicClient({
|
|
75
|
+
chain: base,
|
|
76
|
+
transport: http(BASE_RPC_URL),
|
|
77
|
+
}) as PublicClient
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Build the input data needed for proof generation.
|
|
82
|
+
* This fetches the current storage proof from the blockchain.
|
|
83
|
+
*
|
|
84
|
+
* @param address - The wallet address to prove balance for
|
|
85
|
+
* @param verifiedBalance - The balance threshold to prove (e.g., 5000 tokens)
|
|
86
|
+
*/
|
|
87
|
+
async buildInput(address: string, verifiedBalance: bigint): Promise<BuildInputResult> {
|
|
88
|
+
const balanceSlotHex = pad(toHex(ZKCLAW_TOKEN.balanceSlot))
|
|
89
|
+
const storageKey = keccak256(concat([pad(address as `0x${string}`), balanceSlotHex]))
|
|
90
|
+
|
|
91
|
+
const block = await this.client.getBlock()
|
|
92
|
+
const ethProof = await this.client.getProof({
|
|
93
|
+
address: ZKCLAW_TOKEN.address,
|
|
94
|
+
storageKeys: [storageKey],
|
|
95
|
+
blockNumber: block.number,
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
const input = {
|
|
99
|
+
storageHash: ethProof.storageHash,
|
|
100
|
+
storageProof: ethProof.storageProof,
|
|
101
|
+
chainId: `0x${ZKCLAW_TOKEN.chainId.toString(16)}`,
|
|
102
|
+
blockNumber: `0x${block.number.toString(16)}`,
|
|
103
|
+
tokenAddress: ZKCLAW_TOKEN.address,
|
|
104
|
+
balanceSlot: balanceSlotHex,
|
|
105
|
+
verifiedBalance: `0x${verifiedBalance.toString(16)}`,
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Message to sign (excludes storageProof for brevity)
|
|
109
|
+
const message = JSON.stringify({ ...input, storageProof: undefined })
|
|
110
|
+
|
|
111
|
+
return { input, message }
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Generate a ZK proof of token balance.
|
|
116
|
+
*
|
|
117
|
+
* @param args - Input data including signature and storage proof
|
|
118
|
+
*/
|
|
119
|
+
async generateProof(args: GenerateProofInput): Promise<ProofData> {
|
|
120
|
+
const { pubKeyX, pubKeyY } = await getPublicKey(
|
|
121
|
+
args.signature as `0x${string}`,
|
|
122
|
+
args.messageHash as `0x${string}`
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
const storageProof = args.storageProof[0]
|
|
126
|
+
const nodes = storageProof.proof.slice(0, storageProof.proof.length - 1)
|
|
127
|
+
const leaf = storageProof.proof[storageProof.proof.length - 1]
|
|
128
|
+
|
|
129
|
+
const input = {
|
|
130
|
+
signature: formatHexArray(args.signature, { length: 64 }),
|
|
131
|
+
message_hash: formatHexArray(args.messageHash),
|
|
132
|
+
pub_key_x: formatHexArray(pubKeyX),
|
|
133
|
+
pub_key_y: formatHexArray(pubKeyY),
|
|
134
|
+
storage_hash: formatHexArray(args.storageHash),
|
|
135
|
+
storage_nodes: formatArray(
|
|
136
|
+
nodes,
|
|
137
|
+
(node) => formatHexArray(node, { length: 1080, pad: 'right' }),
|
|
138
|
+
{ length: 5 }
|
|
139
|
+
),
|
|
140
|
+
storage_leaf: formatHexArray(leaf, { length: 120, pad: 'right' }),
|
|
141
|
+
storage_depth: storageProof.proof.length,
|
|
142
|
+
storage_value: `0x${storageProof.value.toString(16)}`,
|
|
143
|
+
chain_id: args.chainId,
|
|
144
|
+
block_number: args.blockNumber,
|
|
145
|
+
token_address: args.tokenAddress,
|
|
146
|
+
balance_slot: args.balanceSlot,
|
|
147
|
+
verified_balance: args.verifiedBalance,
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const proof = await super.generate(input)
|
|
151
|
+
|
|
152
|
+
return {
|
|
153
|
+
proof: Array.from(proof.proof),
|
|
154
|
+
publicInputs: proof.publicInputs,
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Verify a ZK proof.
|
|
160
|
+
*/
|
|
161
|
+
async verifyProof(proof: ProofData): Promise<boolean> {
|
|
162
|
+
return super.verify({
|
|
163
|
+
proof: new Uint8Array(proof.proof),
|
|
164
|
+
publicInputs: proof.publicInputs,
|
|
165
|
+
})
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Parse the public inputs from a proof into readable data.
|
|
170
|
+
*/
|
|
171
|
+
parseData(publicInputs: string[]): CredentialData {
|
|
172
|
+
const balance = BigInt(publicInputs[0]).toString()
|
|
173
|
+
const chainId = Number(BigInt(publicInputs[1]).toString())
|
|
174
|
+
const blockNumber = BigInt(publicInputs[2]).toString()
|
|
175
|
+
const tokenAddress = `0x${publicInputs[3].slice(-40)}`
|
|
176
|
+
const balanceSlot = BigInt(publicInputs[4]).toString()
|
|
177
|
+
const storageHash = `0x${publicInputs
|
|
178
|
+
.slice(5, 5 + 32)
|
|
179
|
+
.map((b) => BigInt(b).toString(16).padStart(2, '0'))
|
|
180
|
+
.join('')}`
|
|
181
|
+
|
|
182
|
+
return {
|
|
183
|
+
balance,
|
|
184
|
+
chainId,
|
|
185
|
+
blockNumber,
|
|
186
|
+
tokenAddress,
|
|
187
|
+
balanceSlot,
|
|
188
|
+
storageHash,
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Check if the proven balance meets the POST threshold.
|
|
194
|
+
*/
|
|
195
|
+
canPost(balance: bigint): boolean {
|
|
196
|
+
return balance >= BALANCE_THRESHOLDS.POST
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Check if the proven balance meets the PROMOTE threshold.
|
|
201
|
+
*/
|
|
202
|
+
canPromote(balance: bigint): boolean {
|
|
203
|
+
return balance >= BALANCE_THRESHOLDS.PROMOTE
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Get balance tier from proof data.
|
|
208
|
+
*/
|
|
209
|
+
getBalanceTier(proofData: ProofData): 'none' | 'post' | 'promote' {
|
|
210
|
+
const data = this.parseData(proofData.publicInputs)
|
|
211
|
+
const balance = BigInt(data.balance)
|
|
212
|
+
|
|
213
|
+
if (this.canPromote(balance)) return 'promote'
|
|
214
|
+
if (this.canPost(balance)) return 'post'
|
|
215
|
+
return 'none'
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// Singleton instance
|
|
220
|
+
let verifierInstance: AnonBalanceVerifier | null = null
|
|
221
|
+
|
|
222
|
+
export function getVerifier(): AnonBalanceVerifier {
|
|
223
|
+
if (!verifierInstance) {
|
|
224
|
+
verifierInstance = new AnonBalanceVerifier()
|
|
225
|
+
}
|
|
226
|
+
return verifierInstance
|
|
227
|
+
}
|