Skip to content
63 changes: 63 additions & 0 deletions packages/core/src/actions/public/getFreezeList.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import type { Client } from '../../clients/createClient.js'

/**
* Parameters for {@link getFreezeList}.
*
* @property programId Freezelist-owning program to read, such as
* `shield_swap_freezelist.aleo`. Each compliance-gated program keeps its own
* list, so an AMM and every ARC-22 wrapper it settles through are separate
* reads.
*/
export type GetFreezeListParameters = { programId: string }

/**
* A compliance freezelist, as a flat Merkle tree.
*
* Entries are field elements in decimal form, ordered bottom-up: the leaf row
* first, then each successive row of internal nodes, with the Merkle root last.
* A tree of `n` leaves has `2n - 1` entries.
*
* Leaves are the frozen addresses cast to fields, sorted ascending and
* left-padded with `0` to reach a power of two, so the tree is the smallest
* power-of-two shape that fits the list rather than a fixed-depth structure. An
* empty list reads as `['0', '0', '<root>']` — two padding leaves and their
* hash.
*/
export type GetFreezeListReturnType = string[]

/**
* Fetches a program's compliance freezelist as a flat Merkle tree.
*
* Applies when constructing the Merkle non-inclusion proofs that
* compliance-gated transitions take. The response carries every node a proof
* needs, including the internal ones, so assembling a proof from it is pure
* indexing and requires no hashing.
*
* The tree changes shape whenever the list changes — adding an address that
* crosses a power of two doubles the leaf row — so its root, the last entry,
* is the only durable identity it has and the natural key for caching it.
* Hits the network.
*
* @param client Client whose transport serves the query.
* @param params Program whose freezelist to read.
* @returns The tree, leaf row first and root last. See
* {@link GetFreezeListReturnType} for the layout.
* @throws When the program tracks no freezelist, the node answers 404 and the
* transport raises. A program with no list is distinct from a program whose
* list is empty, and the latter returns a two-leaf tree.
*
* @example
* const tree = await client.getFreezeList({
* programId: 'shield_swap_freezelist.aleo',
* })
* const root = tree[tree.length - 1]
*/
export async function getFreezeList(
client: Client,
params: GetFreezeListParameters,
): Promise<GetFreezeListReturnType> {
return client.request({
method: 'getFreezeList',
params: { programId: params.programId },
}) as Promise<GetFreezeListReturnType>
}
3 changes: 3 additions & 0 deletions packages/core/src/clients/decorators/public.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import { findBlockHash, type FindBlockHashParameters, type FindBlockHashReturnTy
import { findTransactionId, type FindTransactionIdParameters, type FindTransactionIdReturnType } from '../../actions/public/findTransactionId.js'
import { getTransitions, type GetTransitionsParameters, type GetTransitionsReturnType } from '../../actions/public/getTransitions.js'
import { findTransitionId, type FindTransitionIdParameters, type FindTransitionIdReturnType } from '../../actions/public/findTransitionId.js'
import { getFreezeList, type GetFreezeListParameters, type GetFreezeListReturnType } from '../../actions/public/getFreezeList.js'
import { getMappingNames, type GetMappingNamesParameters, type GetMappingNamesReturnType } from '../../actions/public/getMappingNames.js'
import { getDeploymentTransaction, type GetDeploymentTransactionParameters, type GetDeploymentTransactionReturnType } from '../../actions/public/getDeploymentTransaction.js'
import { getProgramCalls, type GetProgramCallsParameters, type GetProgramCallsReturnType } from '../../actions/public/getProgramCalls.js'
Expand Down Expand Up @@ -99,6 +100,7 @@ export type PublicActions = {
getTransitions: (params: GetTransitionsParameters) => Promise<GetTransitionsReturnType>
findTransitionId: (params: FindTransitionIdParameters) => Promise<FindTransitionIdReturnType>
getMappingNames: (params: GetMappingNamesParameters) => Promise<GetMappingNamesReturnType>
getFreezeList: (params: GetFreezeListParameters) => Promise<GetFreezeListReturnType>
getDeploymentTransaction: (params: GetDeploymentTransactionParameters) => Promise<GetDeploymentTransactionReturnType>
getProgramCalls: (params: GetProgramCallsParameters) => Promise<GetProgramCallsReturnType>
getCommittee: (params?: GetCommitteeParameters) => Promise<GetCommitteeReturnType>
Expand Down Expand Up @@ -176,6 +178,7 @@ export function publicActions(client: Client): PublicActions {
getTransitions: (params) => getTransitions(client, params),
findTransitionId: (params) => findTransitionId(client, params),
getMappingNames: (params) => getMappingNames(client, params),
getFreezeList: (params) => getFreezeList(client, params),
getDeploymentTransaction: (params) => getDeploymentTransaction(client, params),
getProgramCalls: (params) => getProgramCalls(client, params),
getCommittee: (params) => getCommittee(client, params),
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,7 @@ export { findTransactionId } from './actions/public/findTransactionId.js'
export { getTransitions } from './actions/public/getTransitions.js'
export { findTransitionId } from './actions/public/findTransitionId.js'
export { getMappingNames } from './actions/public/getMappingNames.js'
export { getFreezeList } from './actions/public/getFreezeList.js'
export { getDeploymentTransaction } from './actions/public/getDeploymentTransaction.js'
export { getProgramCalls } from './actions/public/getProgramCalls.js'
export { getProgramCallsPaginated } from './actions/public/getProgramCallsPaginated.js'
Expand Down
4 changes: 4 additions & 0 deletions packages/core/src/transports/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,10 @@ function buildUrl(
case 'getProgramMetricsByRange':
return { url: `${base}/metrics/program/${enc(params?.programId)}/range/${enc(params?.days)}`, httpMethod: 'GET' }

// --- Compliance ---
case 'getFreezeList':
return { url: `${base}/programs/${enc(params?.programId)}/compliance/freeze-list`, httpMethod: 'GET' }

// --- Account ---
case 'getBalance':
return { url: `${base}/program/credits.aleo/mapping/account/${enc(params?.address)}`, httpMethod: 'GET' }
Expand Down
55 changes: 55 additions & 0 deletions packages/core/test/actions/public/getFreezeList.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { describe, it, expect, vi } from 'vitest'
import { getFreezeList } from '../../../src/actions/public/getFreezeList.js'

// The live response for an untouched list, as served by
// shield_swap_freezelist.aleo on testnet: two 0field padding leaves and their
// hash. The root matches the constant the program's `initialize` writes.
const EMPTY_LIST = [
'0',
'0',
'3642222252059314292809609689035560016959342421640560347114299934615987159853',
]

describe('getFreezeList', () => {
it('returns the tree and forwards the program id', async () => {
const client = { request: vi.fn().mockResolvedValue(EMPTY_LIST) } as any
const result = await getFreezeList(client, { programId: 'shield_swap_freezelist.aleo' })

expect(result).toEqual(EMPTY_LIST)
expect(client.request).toHaveBeenCalledWith({
method: 'getFreezeList',
params: { programId: 'shield_swap_freezelist.aleo' },
})
})

it('reads an empty list as a two-leaf tree whose last entry is the root', async () => {
const client = { request: vi.fn().mockResolvedValue(EMPTY_LIST) } as any
const tree = await getFreezeList(client, { programId: 'shield_swap_freezelist.aleo' })

// 2n - 1 entries for n leaves; the padding leaves sort below every address.
expect(tree).toHaveLength(3)
expect(tree.slice(0, 2)).toEqual(['0', '0'])
expect(tree[tree.length - 1]).toBe(EMPTY_LIST[2])
})

it('returns a populated tree unparsed, root last', async () => {
// Seven frozen addresses pad to eight leaves: 8 leaves + 4 + 2 + 1 = 15.
const tree = Array.from({ length: 15 }, (_unused, i) => String(i + 1))
const client = { request: vi.fn().mockResolvedValue(tree) } as any
const result = await getFreezeList(client, { programId: 'shield_swap_freezelist.aleo' })

expect(result).toEqual(tree)
expect(result).toHaveLength(15)
})

it('surfaces the transport error when a program tracks no freezelist', async () => {
// A missing list is a 404, distinct from an empty list's two-leaf tree.
const client = {
request: vi.fn().mockRejectedValue(new Error('HTTP 404: No current freeze list found')),
} as any

await expect(getFreezeList(client, { programId: 'no_such_freezelist.aleo' })).rejects.toThrow(
'HTTP 404',
)
})
})
18 changes: 18 additions & 0 deletions packages/core/test/transports/http.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,24 @@ describe('http transport', () => {
)
})

it('routes getFreezeList to the program compliance endpoint', async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve(['0', '0', '3642222252059314292809609689035560016959342421640560347114299934615987159853']),
})

const transport = http('https://api.provable.com/v2', { fetchFn: mockFetch, network: 'testnet' })
await transport.request({
method: 'getFreezeList',
params: { programId: 'shield_swap_freezelist.aleo' },
})

expect(mockFetch).toHaveBeenCalledWith(
'https://api.provable.com/v2/testnet/programs/shield_swap_freezelist.aleo/compliance/freeze-list',
expect.objectContaining({ method: 'GET' }),
)
})

it('makes GET requests with params encoded in URL', async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
Expand Down
Loading
Loading