@zkclaw/credentials 1.0.0 → 1.0.2

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.
@@ -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
+ })
@@ -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('5000') * BigInt(10 ** 18), // 5K tokens
34
+ PROMOTE: BigInt('2000000') * BigInt(10 ** 18), // 2M 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
+ }