From c3772ed5d5ac4195813eb7ca5f9d67526b923104 Mon Sep 17 00:00:00 2001 From: Alex Pitsikoulis Date: Mon, 22 Sep 2025 14:04:06 -0700 Subject: [PATCH 01/11] added RecordScanner class as an implementation of the RecordProvider interface --- sdk/src/browser.ts | 2 + sdk/src/record-scanner.ts | 337 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 339 insertions(+) create mode 100644 sdk/src/record-scanner.ts diff --git a/sdk/src/browser.ts b/sdk/src/browser.ts index 47313d8a7..7c12a1e6b 100644 --- a/sdk/src/browser.ts +++ b/sdk/src/browser.ts @@ -52,6 +52,7 @@ import { NetworkRecordProvider, RecordProvider, } from "./record-provider.js"; +import { RecordScanner } from "./record-scanner.js"; // @TODO: This function is no longer needed, remove it. async function initializeWasm() { @@ -177,6 +178,7 @@ export { RecordsFilter, RecordsResponseFilter, RecordProvider, + RecordScanner, RecordSearchParams, SolutionJSON, SolutionsJSON, diff --git a/sdk/src/record-scanner.ts b/sdk/src/record-scanner.ts new file mode 100644 index 000000000..ed37646b4 --- /dev/null +++ b/sdk/src/record-scanner.ts @@ -0,0 +1,337 @@ +import { Account } from "./account"; +import { EncryptedRecord } from "./models/record-provider/encryptedRecord"; +import { OwnedFilter } from "./models/record-scanner/ownedFilter"; +import { OwnedRecord } from "./models/record-provider/ownedRecord"; +import { RecordProvider } from "./record-provider"; +import { RecordPlaintext } from "./wasm"; +import { RecordSearchParams } from "./models/record-provider/recordSearchParams"; +import { RecordsFilter } from "./models/record-scanner/recordsFilter"; +import { RecordsResponseFilter } from "./models/record-scanner/recordsResponseFilter"; +import { RegistrationRequest } from "./models/record-scanner/registrationRequest"; + +type RecordScannerOptions = { + url: string; + account?: Account; + apiKey?: string | { header: string, value: string }; +} + +/** + * RecordScanner is a RecordProvider implementation that uses the record scanner service to find records. + * + * @example + * const account = new Account({ privateKey: 'APrivateKey1...' }); + * + * const recordScanner = new RecordScanner({ url: "https://record-scanner.aleo.org" }); + * recordScanner.setAccount(account); + * recordScanner.setApiKey("your-api-key"); + * const uuid = await recordScanner.register(0); + * + * const filter = { + * uuid, + * filter: { + * program: "credits.aleo", + * records: ["credits"], + * }, + * responseFilter: { + * commitment: true, + * owner: true, + * tag: true, + * tag?: boolean; + * sender: true, + * spent: true, + * record_ciphertext: true, + * block_height: true; + * block_timestamp: true; + * output_index: true; + * record_name: true; + * function_name: true; + * program_name: true; + * transition_id: true; + * transaction_id: true; + * transaction_index: true; + * transition_index: true; + * }, + * unspent: true, + * }; + * + * const records = await recordScanner.findRecords(filter); + */ +class RecordScanner implements RecordProvider { + account?: Account; + readonly url: string; + private apiKey?: { header: string, value: string }; + private uuid?: string; + + constructor(options: RecordScannerOptions) { + this.url = options.url; + this.account = options.account; + this.apiKey = typeof options.apiKey === "string" ? { header: "X-Provable-API-Key", value: options.apiKey } : options.apiKey; + } + + /** + * Set the account to use for the record scanner. + * + * @param {Account} account The account to use for the record scanner. + */ + async setAccount(account: Account): Promise { + this.uuid = undefined; + this.account = account; + } + + /** + * Set the API key to use for the record scanner. + * + * @param {string} apiKey The API key to use for the record scanner. + */ + async setApiKey(apiKey: string | { header: string, value: string }): Promise { + this.apiKey = typeof apiKey === "string" ? { header: "X-Provable-API-Key", value: apiKey } : apiKey; + } + + /** + * Register the account with the record scanner service. + * + * @param {number} startBlock The block height to start scanning from. + * @returns {Promise} The response from the record scanner service. + */ + async register(startBlock: number): Promise { + let request: RegistrationRequest; + if (!this.account) { + throw new Error("Account not set"); + } else { + request = { + view_key: this.account.viewKey().to_string(), + start: startBlock, + }; + } + + try { + const response = await this.request( + new Request(`${this.url}/register`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(request), + }) + ); + + const data = await response.json(); + this.uuid = data.uuid; + return data; + } catch (error) { + console.error(`Failed to register view key: ${error}`); + throw error; + } + } + + /** + * Get encrypted records from the record scanner service. + * + * @param {RecordsFilter} recordsFilter The filter to use to find the records and filter the response. + * @returns {Promise} The encrypted records. + */ + async encryptedRecords(recordsFilter: RecordsFilter): Promise { + try { + const response = await this.request( + new Request(`${this.url}/records/encrypted`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(recordsFilter), + }), + ); + + return await response.json(); + } catch (error) { + console.error(`Failed to get encrypted records: ${error}`); + throw error; + } + } + + /** + * Check if a list of serial numbers exist in the record scanner service. + * + * @param {string[]} serialNumbers The serial numbers to check. + * @returns {Promise>} Map of Aleo Record serial numbers and whether they appeared in any inputs on chain. If boolean corresponding to the Serial Number has a true value, that Record is considered spent by the Aleo Network. + */ + async checkSerialNumbers(serialNumbers: string[]): Promise> { + try { + const response = await this.request( + new Request(`${this.url}/records/sns`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(serialNumbers), + }), + ); + + return await response.json(); + } catch (error) { + console.error(`Failed to check if serial numbers exist: ${error}`); + throw error; + } + } + + /** + * Check if a list of tags exist in the record scanner service. + * + * @param {string[]} tags The tags to check. + * @returns {Promise>} Map of Aleo Record tags and whether they appeared in any inputs on chain. If boolean corresponding to the tag has a true value, that Record is considered spent by the Aleo Network. + */ + async checkTags(tags: string[]): Promise> { + try { + const response = await this.request( + new Request(`${this.url}/records/tags`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(tags), + }), + ); + + return await response.json(); + } catch (error) { + console.error(`Failed to check if tags exist: ${error}`); + throw error; + } + } + + /** + * Find a record in the record scanner service. + * + * @param {OwnedFilter} searchParameters The filter to use to find the record. + * @returns {Promise} The record. + */ + async findRecord(searchParameters: OwnedFilter): Promise { + try { + const records = await this.findRecords(searchParameters); + + if (records.length > 0) { + return records[0]; + } + + throw new Error("Record not found"); + } catch (error) { + console.error(`Failed to find record: ${error}`); + throw error; + } + } + + /** + * Find records in the record scanner service. + * + * @param {OwnedFilter} filter The filter to use to find the records. + * @returns {Promise} The records. + */ + async findRecords(filter: OwnedFilter): Promise { + if (!this.uuid) { + throw new Error("Not registered"); + } + + filter.uuid = this.uuid; + + try { + const response = await this.request( + new Request(`${this.url}/records/owned`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(filter), + }), + ); + + return await response.json(); + } catch (error) { + console.error(`Failed to get owned records: ${error}`); + throw error; + } + } + + /** + * Find a credits record in the record scanner service. + * + * @param {number} microcredits The amount of microcredits to find. + * @param {OwnedFilter} searchParameters The filter to use to find the record. + * @returns {Promise} The record. + */ + async findCreditsRecord(microcredits: number, searchParameters: OwnedFilter): Promise { + try { + const records = await this.findRecords({ + decrypt: true, + unspent: searchParameters.unspent ?? false, + filter: { + start: searchParameters.filter?.start ?? 0, + program: "credits.aleo", + record: "credits", + }, + uuid: this.uuid, + }); + + const record = records.find(record => { + const plaintext = RecordPlaintext.fromString(record.record_plaintext ?? ''); + const amountStr = plaintext.getMember("microcredits").toString(); + const amount = parseInt(amountStr.replace("u64", "")); + return amount >= microcredits; + }); + + if (!record) { + throw new Error("Record not found"); + } + + return record; + } catch (error) { + console.error(`Failed to find credits record: ${error}`); + throw error; + } + } + + /** + * Find credits records using a record scanning service. + * + * @param {number[]} microcreditAmounts The amounts of microcredits to find. + * @param {OwnedFilter} searchParameters The filter to use to find the records. + * @returns {Promise} The records + */ + async findCreditsRecords(microcreditAmounts: number[], searchParameters: OwnedFilter): Promise { + try { + const records = await this.findRecords({ + decrypt: true, + unspent: searchParameters.unspent ?? false, + filter: { + start: searchParameters.filter?.start ?? 0, + program: "credits.aleo", + record: "credits", + }, + uuid: this.uuid, + }); + return records.filter(record => { + const plaintext = RecordPlaintext.fromString(record.record_plaintext ?? ''); + const amount = plaintext.getMember("microcredits").toString(); + return microcreditAmounts.includes(parseInt(amount.replace("u64", ""))); + }); + } catch (error) { + console.error(`Failed to find credits records: ${error}`); + throw error; + } + } + + /** + * Wrapper function to make a request to the record scanner service and handle any errors. + * + * @param {Request} req The request to make. + * @returns {Promise} The response. + */ + private async request(req: Request): Promise { + try { + if (this.apiKey) { + req.headers.set(this.apiKey.header, this.apiKey.value); + } + const response = await fetch(req); + + if (!response.ok) { + throw new Error(await response.text() ?? `Request to ${req.url} failed with status ${response.status}`); + } + + return response; + } catch (error) { + console.error(`Failed to make request to ${req.url}: ${error}`); + throw error; + } + } +} + +export { RecordScanner }; From d52f0cd0c4f4d0cd3bf5bb593f8c71b46d6f26e3 Mon Sep 17 00:00:00 2001 From: Alex Pitsikoulis Date: Mon, 22 Sep 2025 16:35:08 -0700 Subject: [PATCH 02/11] added unit tests for RecordScanner --- .../record-scanner/recordsResponseFilter.ts | 6 +- .../record-scanner/registrationResponse.ts | 2 +- sdk/src/record-scanner.ts | 1 + sdk/tests/data/records.ts | 5 +- sdk/tests/record-scanner.test.ts | 362 ++++++++++++++++++ 5 files changed, 373 insertions(+), 3 deletions(-) create mode 100644 sdk/tests/record-scanner.test.ts diff --git a/sdk/src/models/record-scanner/recordsResponseFilter.ts b/sdk/src/models/record-scanner/recordsResponseFilter.ts index a971a5b1f..172751a6e 100644 --- a/sdk/src/models/record-scanner/recordsResponseFilter.ts +++ b/sdk/src/models/record-scanner/recordsResponseFilter.ts @@ -5,6 +5,7 @@ * @example * const recordsResponseFilter: RecordsResponseFilter = { * block_height: true, + * block_timestamp: true, * checksum: true, * commitment: true, * record_ciphertext: true, @@ -14,6 +15,7 @@ * owner: true, * program_name: true, * record_name: true, + * sender_ciphertext: true, * transaction_id: true, * transition_id: true, * transaction_index: true, @@ -21,10 +23,12 @@ * } */ export type RecordsResponseFilter = { - blockHeight?: boolean; + block_height?: boolean; + block_timestamp?: boolean; checksum?: boolean; commitment?: boolean; record_ciphertext?: boolean; + sender_ciphertext?: boolean; function_name?: boolean; nonce?: boolean; output_index?: boolean; diff --git a/sdk/src/models/record-scanner/registrationResponse.ts b/sdk/src/models/record-scanner/registrationResponse.ts index 12177f2e9..d67003f37 100644 --- a/sdk/src/models/record-scanner/registrationResponse.ts +++ b/sdk/src/models/record-scanner/registrationResponse.ts @@ -8,7 +8,7 @@ * status: "pending", * } */ -interface RegistrationResponse { +export interface RegistrationResponse { uuid: string, job_id?: string, status?: string diff --git a/sdk/src/record-scanner.ts b/sdk/src/record-scanner.ts index ed37646b4..086bd057e 100644 --- a/sdk/src/record-scanner.ts +++ b/sdk/src/record-scanner.ts @@ -8,6 +8,7 @@ import { RecordSearchParams } from "./models/record-provider/recordSearchParams" import { RecordsFilter } from "./models/record-scanner/recordsFilter"; import { RecordsResponseFilter } from "./models/record-scanner/recordsResponseFilter"; import { RegistrationRequest } from "./models/record-scanner/registrationRequest"; +import { RegistrationResponse } from "./models/record-scanner/registrationResponse"; type RecordScannerOptions = { url: string; diff --git a/sdk/tests/data/records.ts b/sdk/tests/data/records.ts index 7d53ecf76..36217d830 100644 --- a/sdk/tests/data/records.ts +++ b/sdk/tests/data/records.ts @@ -206,7 +206,10 @@ const OWNED_CREDITS_RECORDS = [ } ] -const CHECK_SNS_RESPONSE = { '3673836024253895240205884165890003872225300531298514519146928213266356324646field': true } +const CHECK_SNS_RESPONSE = { + '1621694306596217216370326054181178914897851479837084979111511176605457690717field': true, + '5684626152578699086223993752521225507576791345254401210560771329591763880242field': false, +} const CHECK_TAGS_RESPONSE = { '2965517500209150226508265073635793457193572667031485750956287906078711930968field': false, diff --git a/sdk/tests/record-scanner.test.ts b/sdk/tests/record-scanner.test.ts new file mode 100644 index 000000000..475befea7 --- /dev/null +++ b/sdk/tests/record-scanner.test.ts @@ -0,0 +1,362 @@ +import { expect } from "chai"; +import sinon from "sinon"; +import { RecordScanner } from "../src/record-scanner"; +import { Account } from "../src/account"; +import { CHECK_SNS_RESPONSE, CHECK_TAGS_RESPONSE, ENCRYPTED_RECORDS, OWNED_RECORDS } from "./data/records"; +import { RecordsResponseFilter } from "../src/models/record-scanner/recordsResponseFilter"; +import { RecordsFilter } from "../src/models/record-scanner/recordsFilter"; +import { OwnedFilter } from "../src/models/record-scanner/ownedFilter"; +import { OwnedRecordsResponseFilter } from "../src/models/record-scanner/ownedRecordsResponseFilter"; + +describe("RecordScanner", () => { + const defaultAccount = new Account({ privateKey: "APrivateKey1zkp8CZNn3yeCseEtxuVPbDCwSyhGW6yZKUYKfgXmcpoGPWH" }); + let recordScanner: RecordScanner; + let fetchStub: sinon.SinonStub; + + beforeEach(() => { + fetchStub = sinon.stub(globalThis, 'fetch'); + }); + + afterEach(() => { + sinon.restore(); + }); + + it("should intialize with the correct url", async () => { + recordScanner = new RecordScanner({ url: "https://record-scanner.aleo.org" }); + expect(recordScanner.url).equal("https://record-scanner.aleo.org"); + }); + + it("should intialize with the correct account", async () => { + recordScanner = new RecordScanner({ url: "https://record-scanner.aleo.org", account: defaultAccount }); + expect(recordScanner.account).equal(defaultAccount); + }); + + it("should intialize with the correct api key as a string", async () => { + recordScanner = new RecordScanner({ url: "https://record-scanner.aleo.org", account: defaultAccount, apiKey: "1234567890" }); + + const mockResponse = { + ok: true, + status: 201, + text: () => Promise.resolve('{"uuid": "test-uuid"}'), + json: () => Promise.resolve({ uuid: "test-uuid" }) + }; + + fetchStub.resolves(mockResponse); + await recordScanner.register(0); + + const request = fetchStub.firstCall.args[0] as Request; + expect(request.headers.get("X-Provable-API-Key")).to.equal("1234567890"); + }); + + it("should intialize with the correct api key as an object", async () => { + recordScanner = new RecordScanner({ url: "https://record-scanner.aleo.org", account: defaultAccount, apiKey: { header: "Some-API-Key", value: "1234567890" } }); + + const mockResponse = { + ok: true, + status: 201, + text: () => Promise.resolve('{"uuid": "test-uuid"}'), + json: () => Promise.resolve({ uuid: "test-uuid" }) + }; + + fetchStub.resolves(mockResponse); + await recordScanner.register(0); + + const request = fetchStub.firstCall.args[0] as Request; + expect(request.headers.get("Some-API-Key")).to.equal("1234567890"); + }); + + it("should return RegistrationResponse after successfully registering the account", async () => { + recordScanner = new RecordScanner({ url: "https://record-scanner.aleo.org", account: defaultAccount }); + + const mockResponse = { + ok: true, + status: 201, + text: () => Promise.resolve('{"uuid": "test-uuid"}'), + json: () => Promise.resolve({ uuid: "test-uuid" }) + }; + + fetchStub.resolves(mockResponse); + const registrationResponse = await recordScanner.register(0); + + expect(fetchStub.calledOnce).to.be.true; + const request = fetchStub.firstCall.args[0] as Request; + expect(request.url).to.equal("https://record-scanner.aleo.org/register"); + expect(request.method).to.equal("POST"); + expect(request.headers.get("Content-Type")).to.equal("application/json"); + + const body = await request.text(); + const expectedBody = JSON.stringify({ view_key: defaultAccount.viewKey().to_string(), start: 0 }); + expect(body).to.equal(expectedBody); + + expect(registrationResponse.uuid).equal("test-uuid"); + }); + + it("should return the optional fields of RegistrationResponse if present after successfully registering the account", async () => { + recordScanner = new RecordScanner({ url: "https://record-scanner.aleo.org", account: defaultAccount }); + + const mockResponse = { + ok: true, + status: 201, + text: () => Promise.resolve('{"uuid": "test-uuid", "job_id": "test-job-id", "status": "pending"}'), + json: () => Promise.resolve({ uuid: "test-uuid", job_id: "test-job-id", status: "pending" }) + }; + + fetchStub.resolves(mockResponse); + const registrationResponse = await recordScanner.register(0); + + expect(fetchStub.calledOnce).to.be.true; + const request = fetchStub.firstCall.args[0] as Request; + expect(request.url).to.equal("https://record-scanner.aleo.org/register"); + expect(request.method).to.equal("POST"); + expect(request.headers.get("Content-Type")).to.equal("application/json"); + + const body = await request.text(); + const expectedBody = JSON.stringify({ view_key: defaultAccount.viewKey().to_string(), start: 0 }); + expect(body).to.equal(expectedBody); + + expect(registrationResponse.uuid).equal("test-uuid"); + expect(registrationResponse.job_id).equal("test-job-id"); + expect(registrationResponse.status).equal("pending"); + }); + + it("should throw an error if the account is not set", async () => { + recordScanner = new RecordScanner({ url: "https://record-scanner.aleo.org" }); + let failed = false; + try { + await recordScanner.register(0); + } catch (err: any) { + expect(err).to.be.instanceOf(Error); + expect(err.message).to.equal("Account not set"); + failed = true; + } + expect(failed).to.be.true; + }); + + + it("should return EncryptedRecord[] after successfully getting encrypted records", async () => { + recordScanner = new RecordScanner({ url: "https://record-scanner.aleo.org", account: defaultAccount }); + const mockResponse = { + ok: true, + status: 200, + text: () => Promise.resolve(JSON.stringify(ENCRYPTED_RECORDS)), + json: () => Promise.resolve(ENCRYPTED_RECORDS), + }; + + fetchStub.resolves(mockResponse); + const responseFilter: RecordsResponseFilter = { + commitment: true, + checksum: true, + block_height: true, + program_name: true, + function_name: true, + output_index: true, + owner: true, + record_ciphertext: true, + record_name: true, + nonce: true, + transition_id: true, + transaction_id: true, + transaction_index: true, + transition_index: true, + }; + const filter: RecordsFilter = { + start: 10002000, + end: 10003000, + programs: ["credits.aleo", "token_registry.aleo"], + response: responseFilter, + }; + const encryptedRecords = await recordScanner.encryptedRecords(filter); + expect(encryptedRecords).to.equal(ENCRYPTED_RECORDS); + + const request = fetchStub.firstCall.args[0] as Request; + const body = await request.text(); + const expectedBody = JSON.stringify(filter); + expect(body).to.equal(expectedBody); + expect(request.method).to.equal("POST"); + expect(request.headers.get("Content-Type")).to.equal("application/json"); + }); + + it("should return OwnedRecord[] after successfully getting owned records", async () => { + recordScanner = new RecordScanner({ url: "https://record-scanner.aleo.org", account: defaultAccount }); + const mockRegisterResponse = { + ok: true, + status: 201, + text: () => Promise.resolve('{"uuid": "test-uuid"}'), + json: () => Promise.resolve({ uuid: "test-uuid" }) + }; + fetchStub.resolves(mockRegisterResponse); + await recordScanner.register(0); + + fetchStub.resetHistory(); + + const mockResponse = { + ok: true, + status: 200, + text: () => Promise.resolve(JSON.stringify(OWNED_RECORDS)), + json: () => Promise.resolve(OWNED_RECORDS), + }; + fetchStub.resolves(mockResponse); + + const responseFilter: OwnedRecordsResponseFilter = { + block_height: true, + commitment: true, + function_name: true, + output_index: true, + owner: true, + program_name: true, + record_ciphertext: true, + record_name: true, + spent: true, + tag: true, + transaction_id: true, + transition_id: true, + transaction_index: true, + transition_index: true, + }; + const filter: OwnedFilter = { + uuid: "test-uuid", + decrypt: true, + filter: { + spent: false, + response: responseFilter, + }, + }; + const ownedRecords = await recordScanner.findRecords(filter); + expect(ownedRecords).to.equal(OWNED_RECORDS); + + const request = fetchStub.firstCall.args[0] as Request; + const body = await request.text(); + const expectedBody = JSON.stringify(filter); + expect(body).to.equal(expectedBody); + expect(request.method).to.equal("POST"); + expect(request.headers.get("Content-Type")).to.equal("application/json"); + }); + + it("should throw an error if the uuid is not registered", async () => { + recordScanner = new RecordScanner({ url: "https://record-scanner.aleo.org", account: defaultAccount }); + let failed = false; + try { + await recordScanner.findRecords({ + uuid: "test-uuid", + filter: { + spent: false, + response: { + block_height: true, + commitment: true, + function_name: true, + output_index: true, + owner: true, + }, + }, + decrypt: true, + }); + } catch (err: any) { + expect(err).to.be.instanceOf(Error); + expect(err.message).to.equal("Not registered"); + failed = true; + } + expect(failed).to.be.true; + }); + + it("should return record of string->boolean after successfully checking serial numbers", async () => { + recordScanner = new RecordScanner({ url: "https://record-scanner.aleo.org", account: defaultAccount }); + const mockResponse = { + ok: true, + status: 200, + text: () => Promise.resolve(JSON.stringify(CHECK_SNS_RESPONSE)), + json: () => Promise.resolve(CHECK_SNS_RESPONSE), + }; + fetchStub.resolves(mockResponse); + const sns = await recordScanner.checkSerialNumbers([ + "1621694306596217216370326054181178914897851479837084979111511176605457690717field", + "5684626152578699086223993752521225507576791345254401210560771329591763880242field", + ]); + expect(sns).to.deep.equal(CHECK_SNS_RESPONSE); + + const request = fetchStub.firstCall.args[0] as Request; + const body = await request.text(); + const expectedBody = JSON.stringify([ + "1621694306596217216370326054181178914897851479837084979111511176605457690717field", + "5684626152578699086223993752521225507576791345254401210560771329591763880242field", + ]); + expect(body).to.equal(expectedBody); + expect(request.method).to.equal("POST"); + expect(request.headers.get("Content-Type")).to.equal("application/json"); + }); + + it("should return record of string->boolean after successfully checking tags", async () => { + recordScanner = new RecordScanner({ url: "https://record-scanner.aleo.org", account: defaultAccount }); + const mockResponse = { + ok: true, + status: 200, + text: () => Promise.resolve(JSON.stringify(CHECK_TAGS_RESPONSE)), + json: () => Promise.resolve(CHECK_TAGS_RESPONSE), + }; + fetchStub.resolves(mockResponse); + const tags = await recordScanner.checkTags([ + "2965517500209150226508265073635793457193572667031485750956287906078711930968field", + "8421937347379608036510120951995833971195343843566214313082589116311107280540field", + "5941252181432651644402279701137165256963073258332916685063623109173576520831field", + ]); + expect(tags).to.deep.equal(CHECK_TAGS_RESPONSE); + + const request = fetchStub.firstCall.args[0] as Request; + const body = await request.text(); + const expectedBody = JSON.stringify([ + "2965517500209150226508265073635793457193572667031485750956287906078711930968field", + "8421937347379608036510120951995833971195343843566214313082589116311107280540field", + "5941252181432651644402279701137165256963073258332916685063623109173576520831field", + ]); + expect(body).to.equal(expectedBody); + expect(request.method).to.equal("POST"); + expect(request.headers.get("Content-Type")).to.equal("application/json"); + }); + + it("should handle HTTP errors", async () => { + recordScanner = new RecordScanner({ url: "https://record-scanner.aleo.org", account: defaultAccount }); + let mockResponse = { + ok: false, + status: 500, + text: () => Promise.resolve('{"error": "Internal server error"}'), + }; + + fetchStub.resolves(mockResponse); + let failed = false; + try { + await recordScanner.register(0); + } catch (err: any) { + expect(err).to.be.instanceOf(Error); + expect(err.message).to.equal('{"error": "Internal server error"}'); + failed = true; + } + expect(failed).to.be.true; + + mockResponse = { + ok: false, + status: 422, + text: () => Promise.resolve('{"error": "Invalid view key"}'), + }; + + fetchStub.resolves(mockResponse); + failed = false; + try { + await recordScanner.register(0); + } catch (err: any) { + expect(err).to.be.instanceOf(Error); + expect(err.message).to.equal('{"error": "Invalid view key"}'); + failed = true; + } + expect(failed).to.be.true; + + fetchStub.rejects(new Error("Unknown error")); + failed = false; + try { + await recordScanner.register(0); + } catch (err: any) { + expect(err).to.be.instanceOf(Error); + expect(err.message).to.equal("Unknown error"); + failed = true; + } + expect(failed).to.be.true; + }); +}); \ No newline at end of file From 697e38e20f6de6780884d81db93726e1f6af3f30 Mon Sep 17 00:00:00 2001 From: Alex Pitsikoulis Date: Mon, 22 Sep 2025 18:12:24 -0700 Subject: [PATCH 03/11] added RecordScanner tests for findRecords and findRecord --- sdk/tests/record-scanner.test.ts | 35 ++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/sdk/tests/record-scanner.test.ts b/sdk/tests/record-scanner.test.ts index 475befea7..ebf041f94 100644 --- a/sdk/tests/record-scanner.test.ts +++ b/sdk/tests/record-scanner.test.ts @@ -232,6 +232,41 @@ describe("RecordScanner", () => { expect(request.headers.get("Content-Type")).to.equal("application/json"); }); + it("should return OwnedRecord after successfully getting owned record", async () => { + recordScanner = new RecordScanner({ url: "https://record-scanner.aleo.org", account: defaultAccount }); + const mockRegisterResponse = { + ok: true, + status: 201, + text: () => Promise.resolve('{"uuid": "test-uuid"}'), + json: () => Promise.resolve({ uuid: "test-uuid" }) + }; + fetchStub.resolves(mockRegisterResponse); + await recordScanner.register(0); + + fetchStub.resetHistory(); + + const mockResponse = { + ok: true, + status: 200, + text: () => Promise.resolve(JSON.stringify(OWNED_RECORDS)), + json: () => Promise.resolve(OWNED_RECORDS), + }; + fetchStub.resolves(mockResponse); + const ownedRecord = await recordScanner.findRecord({ + uuid: "test-uuid", + }); + expect(ownedRecord).to.deep.equal(OWNED_RECORDS[0]); + + const request = fetchStub.firstCall.args[0] as Request; + const body = await request.text(); + const expectedBody = JSON.stringify({ + uuid: "test-uuid", + }); + expect(body).to.equal(expectedBody); + expect(request.method).to.equal("POST"); + expect(request.headers.get("Content-Type")).to.equal("application/json"); + }); + it("should throw an error if the uuid is not registered", async () => { recordScanner = new RecordScanner({ url: "https://record-scanner.aleo.org", account: defaultAccount }); let failed = false; From 1ad7987aa2f57d8e4928e59e23d36d616eb06047 Mon Sep 17 00:00:00 2001 From: Alex Pitsikoulis Date: Tue, 23 Sep 2025 13:02:15 -0700 Subject: [PATCH 04/11] added checkStatus method and its unit test to the RecordScanner object --- .../record-scanner/registrationResponse.ts | 2 +- .../models/record-scanner/statusResponse.ts | 13 ++++++++++ sdk/src/record-scanner.ts | 24 +++++++++++++++++++ sdk/tests/record-scanner.test.ts | 20 ++++++++++++++++ 4 files changed, 58 insertions(+), 1 deletion(-) create mode 100644 sdk/src/models/record-scanner/statusResponse.ts diff --git a/sdk/src/models/record-scanner/registrationResponse.ts b/sdk/src/models/record-scanner/registrationResponse.ts index d67003f37..e8e10b644 100644 --- a/sdk/src/models/record-scanner/registrationResponse.ts +++ b/sdk/src/models/record-scanner/registrationResponse.ts @@ -1,5 +1,5 @@ /** - * RegistrationResponse is a type that represents a response from a record scanning service. + * RegistrationResponse is a type that represents a response from a record scanning service's registration endpoint. * * @example * const registrationResponse: RegistrationResponse = { diff --git a/sdk/src/models/record-scanner/statusResponse.ts b/sdk/src/models/record-scanner/statusResponse.ts new file mode 100644 index 000000000..d969393cc --- /dev/null +++ b/sdk/src/models/record-scanner/statusResponse.ts @@ -0,0 +1,13 @@ +/** + * StatusResponse is a type that represents a response from a record scanning service's status endpoint. + * + * @example + * const statusResponse: StatusResponse = { + * synced: true, + * percentage: 100, + * } + */ +export interface StatusResponse { + synced: boolean; + percentage: number; +} \ No newline at end of file diff --git a/sdk/src/record-scanner.ts b/sdk/src/record-scanner.ts index 086bd057e..5ce01bb21 100644 --- a/sdk/src/record-scanner.ts +++ b/sdk/src/record-scanner.ts @@ -9,6 +9,7 @@ import { RecordsFilter } from "./models/record-scanner/recordsFilter"; import { RecordsResponseFilter } from "./models/record-scanner/recordsResponseFilter"; import { RegistrationRequest } from "./models/record-scanner/registrationRequest"; import { RegistrationResponse } from "./models/record-scanner/registrationResponse"; +import { StatusResponse } from "./models/record-scanner/statusResponse"; type RecordScannerOptions = { url: string; @@ -192,6 +193,29 @@ class RecordScanner implements RecordProvider { } } + /** + * Check the status of a record scanner indexing job. + * + * @param {string} jobId The job id to check. + * @returns {Promise} The status of the job. + */ + async checkStatus(jobId: string): Promise { + try { + const response = await this.request( + new Request(`${this.url}/status/${jobId}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(jobId), + }), + ); + + return await response.json(); + } catch (error) { + console.error(`Failed to check status of job: ${error}`); + throw error; + } + } + /** * Find a record in the record scanner service. * diff --git a/sdk/tests/record-scanner.test.ts b/sdk/tests/record-scanner.test.ts index ebf041f94..0b291490b 100644 --- a/sdk/tests/record-scanner.test.ts +++ b/sdk/tests/record-scanner.test.ts @@ -347,6 +347,26 @@ describe("RecordScanner", () => { expect(request.headers.get("Content-Type")).to.equal("application/json"); }); + it("should return StatusResponse after successfully checking status", async () => { + recordScanner = new RecordScanner({ url: "https://record-scanner.aleo.org", account: defaultAccount }); + const mockResponse = { + ok: true, + status: 200, + text: () => Promise.resolve(JSON.stringify({ synced: true, percentage: 100 })), + json: () => Promise.resolve({ synced: true, percentage: 100 }), + }; + fetchStub.resolves(mockResponse); + const statusResponse = await recordScanner.checkStatus("test-job-id"); + expect(statusResponse).to.deep.equal({ synced: true, percentage: 100 }); + + const request = fetchStub.firstCall.args[0] as Request; + const body = await request.text(); + const expectedBody = JSON.stringify("test-job-id"); + expect(body).to.equal(expectedBody); + expect(request.method).to.equal("POST"); + expect(request.headers.get("Content-Type")).to.equal("application/json"); + }); + it("should handle HTTP errors", async () => { recordScanner = new RecordScanner({ url: "https://record-scanner.aleo.org", account: defaultAccount }); let mockResponse = { From 8866f7584e778f951de320616f8841cdd10b1df9 Mon Sep 17 00:00:00 2001 From: Alex Pitsikoulis Date: Wed, 24 Sep 2025 09:16:54 -0700 Subject: [PATCH 05/11] corrected url for RecordScanner status request --- sdk/src/record-scanner.ts | 2 +- sdk/tests/record-scanner.test.ts | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/sdk/src/record-scanner.ts b/sdk/src/record-scanner.ts index 5ce01bb21..ced46081a 100644 --- a/sdk/src/record-scanner.ts +++ b/sdk/src/record-scanner.ts @@ -202,7 +202,7 @@ class RecordScanner implements RecordProvider { async checkStatus(jobId: string): Promise { try { const response = await this.request( - new Request(`${this.url}/status/${jobId}`, { + new Request(`${this.url}/status`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(jobId), diff --git a/sdk/tests/record-scanner.test.ts b/sdk/tests/record-scanner.test.ts index 0b291490b..ccd79ae34 100644 --- a/sdk/tests/record-scanner.test.ts +++ b/sdk/tests/record-scanner.test.ts @@ -172,6 +172,7 @@ describe("RecordScanner", () => { const body = await request.text(); const expectedBody = JSON.stringify(filter); expect(body).to.equal(expectedBody); + expect(request.url).to.equal("https://record-scanner.aleo.org/records/encrypted"); expect(request.method).to.equal("POST"); expect(request.headers.get("Content-Type")).to.equal("application/json"); }); @@ -228,6 +229,7 @@ describe("RecordScanner", () => { const body = await request.text(); const expectedBody = JSON.stringify(filter); expect(body).to.equal(expectedBody); + expect(request.url).to.equal("https://record-scanner.aleo.org/records/owned"); expect(request.method).to.equal("POST"); expect(request.headers.get("Content-Type")).to.equal("application/json"); }); @@ -263,6 +265,7 @@ describe("RecordScanner", () => { uuid: "test-uuid", }); expect(body).to.equal(expectedBody); + expect(request.url).to.equal("https://record-scanner.aleo.org/records/owned"); expect(request.method).to.equal("POST"); expect(request.headers.get("Content-Type")).to.equal("application/json"); }); @@ -315,6 +318,7 @@ describe("RecordScanner", () => { "5684626152578699086223993752521225507576791345254401210560771329591763880242field", ]); expect(body).to.equal(expectedBody); + expect(request.url).to.equal("https://record-scanner.aleo.org/records/sns"); expect(request.method).to.equal("POST"); expect(request.headers.get("Content-Type")).to.equal("application/json"); }); @@ -343,6 +347,7 @@ describe("RecordScanner", () => { "5941252181432651644402279701137165256963073258332916685063623109173576520831field", ]); expect(body).to.equal(expectedBody); + expect(request.url).to.equal("https://record-scanner.aleo.org/records/tags"); expect(request.method).to.equal("POST"); expect(request.headers.get("Content-Type")).to.equal("application/json"); }); @@ -363,6 +368,7 @@ describe("RecordScanner", () => { const body = await request.text(); const expectedBody = JSON.stringify("test-job-id"); expect(body).to.equal(expectedBody); + expect(request.url).to.equal("https://record-scanner.aleo.org/status"); expect(request.method).to.equal("POST"); expect(request.headers.get("Content-Type")).to.equal("application/json"); }); From 230a3d8e9124194dfd8054e40366469a426404d0 Mon Sep 17 00:00:00 2001 From: Mike Turner Date: Tue, 30 Sep 2025 14:24:19 -0400 Subject: [PATCH 06/11] [Feature] Domain separators (#1105) * Create domain separator in function in wasm * Give domain separator camelCase name * Add Record Scanner domain separator to SDK constants --- sdk/src/browser.ts | 1 + sdk/src/constants.ts | 2 ++ wasm/src/types/field.rs | 14 +++++++++++--- 3 files changed, 14 insertions(+), 3 deletions(-) diff --git a/sdk/src/browser.ts b/sdk/src/browser.ts index 7c12a1e6b..78c8e90dc 100644 --- a/sdk/src/browser.ts +++ b/sdk/src/browser.ts @@ -127,6 +127,7 @@ export { PUBLIC_TRANSFER, PUBLIC_TRANSFER_AS_SIGNER, PUBLIC_TO_PRIVATE_TRANSFER, + RECORD_DOMAIN, VALID_TRANSFER_TYPES, } from "./constants.js"; diff --git a/sdk/src/constants.ts b/sdk/src/constants.ts index 400c93082..14680566a 100644 --- a/sdk/src/constants.ts +++ b/sdk/src/constants.ts @@ -111,3 +111,5 @@ export const PUBLIC_TO_PRIVATE_TRANSFER = new Set([ "transfer_public_to_private", "transferPublicToPrivate", ]); + +export const RECORD_DOMAIN = "RecordScannerV0"; diff --git a/wasm/src/types/field.rs b/wasm/src/types/field.rs index 256dfb10a..7b9daf25d 100644 --- a/wasm/src/types/field.rs +++ b/wasm/src/types/field.rs @@ -18,10 +18,10 @@ use crate::{ Plaintext, from_js_typed_array, to_bits_array_le, - types::native::{FieldNative, LiteralNative, PlaintextNative}, + types::native::{CurrentNetwork, FieldNative, LiteralNative, PlaintextNative}, }; -use snarkvm_console::prelude::{Double, FromBits, FromBytes, One, Pow, ToBits, ToBytes, Zero}; -use snarkvm_wasm::utilities::Uniform; +use snarkvm_console::prelude::{Double, Environment, FromBits, FromBytes, One, Pow, ToBits, ToBytes, Zero}; +use snarkvm_wasm::{fields::PrimeField, utilities::Uniform}; use js_sys::{Array, Uint8Array}; use std::{ops::Deref, str::FromStr, sync::OnceLock}; @@ -115,6 +115,14 @@ impl Field { Field(self.0 / other.0) } + /// Initializes a new field as a domain separator. + #[wasm_bindgen(js_name = "newDomainSeparator")] + pub fn new_domain_separator(&self, domain: &str) -> Field { + let domain_native = + FieldNative::new(::Field::from_bytes_le_mod_order(domain.as_bytes())); + Field::from(domain_native) + } + /// Power of a field element. pub fn pow(&self, other: &Field) -> Field { Field(self.0.pow(other.0)) From 6c2d52a78e5579173910d8bd7384cc889ebb1124 Mon Sep 17 00:00:00 2001 From: Alex Pitsikoulis Date: Tue, 30 Sep 2025 11:32:09 -0700 Subject: [PATCH 07/11] added UUID computation to RecordScanner so theres no need to register each time a client spins up --- sdk/src/record-scanner.ts | 24 ++++++++++++++++++------ sdk/tests/record-scanner.test.ts | 10 +++++----- 2 files changed, 23 insertions(+), 11 deletions(-) diff --git a/sdk/src/record-scanner.ts b/sdk/src/record-scanner.ts index ced46081a..a775bdba3 100644 --- a/sdk/src/record-scanner.ts +++ b/sdk/src/record-scanner.ts @@ -3,13 +3,14 @@ import { EncryptedRecord } from "./models/record-provider/encryptedRecord"; import { OwnedFilter } from "./models/record-scanner/ownedFilter"; import { OwnedRecord } from "./models/record-provider/ownedRecord"; import { RecordProvider } from "./record-provider"; -import { RecordPlaintext } from "./wasm"; +import { Field, Poseidon4, RecordPlaintext, ViewKey } from "./wasm"; import { RecordSearchParams } from "./models/record-provider/recordSearchParams"; import { RecordsFilter } from "./models/record-scanner/recordsFilter"; import { RecordsResponseFilter } from "./models/record-scanner/recordsResponseFilter"; import { RegistrationRequest } from "./models/record-scanner/registrationRequest"; import { RegistrationResponse } from "./models/record-scanner/registrationResponse"; import { StatusResponse } from "./models/record-scanner/statusResponse"; +import { RECORD_DOMAIN } from "./constants"; type RecordScannerOptions = { url: string; @@ -68,6 +69,9 @@ class RecordScanner implements RecordProvider { this.url = options.url; this.account = options.account; this.apiKey = typeof options.apiKey === "string" ? { header: "X-Provable-API-Key", value: options.apiKey } : options.apiKey; + if (this.account) { + this.uuid = this.computeUUID(this.account.viewKey()); + } } /** @@ -76,8 +80,8 @@ class RecordScanner implements RecordProvider { * @param {Account} account The account to use for the record scanner. */ async setAccount(account: Account): Promise { - this.uuid = undefined; this.account = account; + this.uuid = this.computeUUID(account.viewKey()); } /** @@ -245,7 +249,7 @@ class RecordScanner implements RecordProvider { */ async findRecords(filter: OwnedFilter): Promise { if (!this.uuid) { - throw new Error("Not registered"); + throw new Error("You are using the RecordScanner implementation of the RecordProvider. No account has been registered with the RecordScanner which is required to use the findRecords method. Please set an with the setAccount method before calling the findRecords method again."); } filter.uuid = this.uuid; @@ -277,7 +281,7 @@ class RecordScanner implements RecordProvider { try { const records = await this.findRecords({ decrypt: true, - unspent: searchParameters.unspent ?? false, + unspent: searchParameters.unspent, filter: { start: searchParameters.filter?.start ?? 0, program: "credits.aleo", @@ -294,7 +298,7 @@ class RecordScanner implements RecordProvider { }); if (!record) { - throw new Error("Record not found"); + throw new Error(`No records found matching the supplied search filter:\n${JSON.stringify(searchParameters, null, 2)}`); } return record; @@ -315,7 +319,7 @@ class RecordScanner implements RecordProvider { try { const records = await this.findRecords({ decrypt: true, - unspent: searchParameters.unspent ?? false, + unspent: searchParameters.unspent, filter: { start: searchParameters.filter?.start ?? 0, program: "credits.aleo", @@ -357,6 +361,14 @@ class RecordScanner implements RecordProvider { throw error; } } + + private computeUUID(vk: ViewKey): Field { + // Construct the material needed for the Poseidon oracle. + const inputs = [Field.newDomainSeparator(RECORD_DOMAIN), vk().toField(), Field.one()] + // Calculate the uuid. + const hasher = new Poseidon4(); + return hasher.hash(inputs); + } } export { RecordScanner }; diff --git a/sdk/tests/record-scanner.test.ts b/sdk/tests/record-scanner.test.ts index ccd79ae34..137bee796 100644 --- a/sdk/tests/record-scanner.test.ts +++ b/sdk/tests/record-scanner.test.ts @@ -1,12 +1,12 @@ -import { expect } from "chai"; -import sinon from "sinon"; -import { RecordScanner } from "../src/record-scanner"; import { Account } from "../src/account"; import { CHECK_SNS_RESPONSE, CHECK_TAGS_RESPONSE, ENCRYPTED_RECORDS, OWNED_RECORDS } from "./data/records"; -import { RecordsResponseFilter } from "../src/models/record-scanner/recordsResponseFilter"; -import { RecordsFilter } from "../src/models/record-scanner/recordsFilter"; +import { expect } from "chai"; import { OwnedFilter } from "../src/models/record-scanner/ownedFilter"; import { OwnedRecordsResponseFilter } from "../src/models/record-scanner/ownedRecordsResponseFilter"; +import { RecordScanner } from "../src/record-scanner"; +import { RecordsFilter } from "../src/models/record-scanner/recordsFilter"; +import { RecordsResponseFilter } from "../src/models/record-scanner/recordsResponseFilter"; +import sinon from "sinon"; describe("RecordScanner", () => { const defaultAccount = new Account({ privateKey: "APrivateKey1zkp8CZNn3yeCseEtxuVPbDCwSyhGW6yZKUYKfgXmcpoGPWH" }); From c0ed7e6645d77ad9b0ea06c5519ecd746ad6f265 Mon Sep 17 00:00:00 2001 From: Alex Pitsikoulis Date: Tue, 30 Sep 2025 14:00:37 -0700 Subject: [PATCH 08/11] removed account field from RecordScanner --- sdk/src/record-scanner.ts | 51 +++++++------------- sdk/tests/record-scanner.test.ts | 82 +++++++++----------------------- wasm/src/types/field.rs | 2 +- 3 files changed, 42 insertions(+), 93 deletions(-) diff --git a/sdk/src/record-scanner.ts b/sdk/src/record-scanner.ts index a775bdba3..0441e8e25 100644 --- a/sdk/src/record-scanner.ts +++ b/sdk/src/record-scanner.ts @@ -1,12 +1,9 @@ -import { Account } from "./account"; import { EncryptedRecord } from "./models/record-provider/encryptedRecord"; import { OwnedFilter } from "./models/record-scanner/ownedFilter"; import { OwnedRecord } from "./models/record-provider/ownedRecord"; import { RecordProvider } from "./record-provider"; import { Field, Poseidon4, RecordPlaintext, ViewKey } from "./wasm"; -import { RecordSearchParams } from "./models/record-provider/recordSearchParams"; import { RecordsFilter } from "./models/record-scanner/recordsFilter"; -import { RecordsResponseFilter } from "./models/record-scanner/recordsResponseFilter"; import { RegistrationRequest } from "./models/record-scanner/registrationRequest"; import { RegistrationResponse } from "./models/record-scanner/registrationResponse"; import { StatusResponse } from "./models/record-scanner/statusResponse"; @@ -14,7 +11,6 @@ import { RECORD_DOMAIN } from "./constants"; type RecordScannerOptions = { url: string; - account?: Account; apiKey?: string | { header: string, value: string }; } @@ -60,28 +56,13 @@ type RecordScannerOptions = { * const records = await recordScanner.findRecords(filter); */ class RecordScanner implements RecordProvider { - account?: Account; readonly url: string; private apiKey?: { header: string, value: string }; - private uuid?: string; + private uuid?: Field; constructor(options: RecordScannerOptions) { this.url = options.url; - this.account = options.account; this.apiKey = typeof options.apiKey === "string" ? { header: "X-Provable-API-Key", value: options.apiKey } : options.apiKey; - if (this.account) { - this.uuid = this.computeUUID(this.account.viewKey()); - } - } - - /** - * Set the account to use for the record scanner. - * - * @param {Account} account The account to use for the record scanner. - */ - async setAccount(account: Account): Promise { - this.account = account; - this.uuid = this.computeUUID(account.viewKey()); } /** @@ -93,24 +74,28 @@ class RecordScanner implements RecordProvider { this.apiKey = typeof apiKey === "string" ? { header: "X-Provable-API-Key", value: apiKey } : apiKey; } + /** + * Set the UUID to use for the record scanner. + * + * @param {Field} uuid The UUID to use for the record scanner. + */ + async setUuid(uuidOrViewKey: Field | ViewKey): Promise { + this.uuid = uuidOrViewKey instanceof ViewKey ? this.computeUUID(uuidOrViewKey) : uuidOrViewKey; + } + /** * Register the account with the record scanner service. * * @param {number} startBlock The block height to start scanning from. * @returns {Promise} The response from the record scanner service. */ - async register(startBlock: number): Promise { - let request: RegistrationRequest; - if (!this.account) { - throw new Error("Account not set"); - } else { - request = { - view_key: this.account.viewKey().to_string(), + async register(viewKey: ViewKey, startBlock: number): Promise { + try { + let request: RegistrationRequest = { + view_key: viewKey.to_string(), start: startBlock, }; - } - try { const response = await this.request( new Request(`${this.url}/register`, { method: "POST", @@ -252,7 +237,7 @@ class RecordScanner implements RecordProvider { throw new Error("You are using the RecordScanner implementation of the RecordProvider. No account has been registered with the RecordScanner which is required to use the findRecords method. Please set an with the setAccount method before calling the findRecords method again."); } - filter.uuid = this.uuid; + filter.uuid = this.uuid?.toString(); try { const response = await this.request( @@ -287,7 +272,7 @@ class RecordScanner implements RecordProvider { program: "credits.aleo", record: "credits", }, - uuid: this.uuid, + uuid: this.uuid?.toString(), }); const record = records.find(record => { @@ -325,7 +310,7 @@ class RecordScanner implements RecordProvider { program: "credits.aleo", record: "credits", }, - uuid: this.uuid, + uuid: this.uuid?.toString(), }); return records.filter(record => { const plaintext = RecordPlaintext.fromString(record.record_plaintext ?? ''); @@ -364,7 +349,7 @@ class RecordScanner implements RecordProvider { private computeUUID(vk: ViewKey): Field { // Construct the material needed for the Poseidon oracle. - const inputs = [Field.newDomainSeparator(RECORD_DOMAIN), vk().toField(), Field.one()] + const inputs = [Field.newDomainSeparator(RECORD_DOMAIN), vk.toField(), Field.one()] // Calculate the uuid. const hasher = new Poseidon4(); return hasher.hash(inputs); diff --git a/sdk/tests/record-scanner.test.ts b/sdk/tests/record-scanner.test.ts index 137bee796..94992b296 100644 --- a/sdk/tests/record-scanner.test.ts +++ b/sdk/tests/record-scanner.test.ts @@ -26,13 +26,8 @@ describe("RecordScanner", () => { expect(recordScanner.url).equal("https://record-scanner.aleo.org"); }); - it("should intialize with the correct account", async () => { - recordScanner = new RecordScanner({ url: "https://record-scanner.aleo.org", account: defaultAccount }); - expect(recordScanner.account).equal(defaultAccount); - }); - it("should intialize with the correct api key as a string", async () => { - recordScanner = new RecordScanner({ url: "https://record-scanner.aleo.org", account: defaultAccount, apiKey: "1234567890" }); + recordScanner = new RecordScanner({ url: "https://record-scanner.aleo.org", apiKey: "1234567890" }); const mockResponse = { ok: true, @@ -42,14 +37,14 @@ describe("RecordScanner", () => { }; fetchStub.resolves(mockResponse); - await recordScanner.register(0); + await recordScanner.register(defaultAccount.viewKey(), 0); const request = fetchStub.firstCall.args[0] as Request; expect(request.headers.get("X-Provable-API-Key")).to.equal("1234567890"); }); it("should intialize with the correct api key as an object", async () => { - recordScanner = new RecordScanner({ url: "https://record-scanner.aleo.org", account: defaultAccount, apiKey: { header: "Some-API-Key", value: "1234567890" } }); + recordScanner = new RecordScanner({ url: "https://record-scanner.aleo.org", apiKey: { header: "Some-API-Key", value: "1234567890" } }); const mockResponse = { ok: true, @@ -59,14 +54,14 @@ describe("RecordScanner", () => { }; fetchStub.resolves(mockResponse); - await recordScanner.register(0); + await recordScanner.register(defaultAccount.viewKey(), 0); const request = fetchStub.firstCall.args[0] as Request; expect(request.headers.get("Some-API-Key")).to.equal("1234567890"); }); it("should return RegistrationResponse after successfully registering the account", async () => { - recordScanner = new RecordScanner({ url: "https://record-scanner.aleo.org", account: defaultAccount }); + recordScanner = new RecordScanner({ url: "https://record-scanner.aleo.org" }); const mockResponse = { ok: true, @@ -76,7 +71,7 @@ describe("RecordScanner", () => { }; fetchStub.resolves(mockResponse); - const registrationResponse = await recordScanner.register(0); + const registrationResponse = await recordScanner.register(defaultAccount.viewKey(), 0); expect(fetchStub.calledOnce).to.be.true; const request = fetchStub.firstCall.args[0] as Request; @@ -92,7 +87,7 @@ describe("RecordScanner", () => { }); it("should return the optional fields of RegistrationResponse if present after successfully registering the account", async () => { - recordScanner = new RecordScanner({ url: "https://record-scanner.aleo.org", account: defaultAccount }); + recordScanner = new RecordScanner({ url: "https://record-scanner.aleo.org" }); const mockResponse = { ok: true, @@ -102,7 +97,7 @@ describe("RecordScanner", () => { }; fetchStub.resolves(mockResponse); - const registrationResponse = await recordScanner.register(0); + const registrationResponse = await recordScanner.register(defaultAccount.viewKey(), 0); expect(fetchStub.calledOnce).to.be.true; const request = fetchStub.firstCall.args[0] as Request; @@ -119,22 +114,9 @@ describe("RecordScanner", () => { expect(registrationResponse.status).equal("pending"); }); - it("should throw an error if the account is not set", async () => { - recordScanner = new RecordScanner({ url: "https://record-scanner.aleo.org" }); - let failed = false; - try { - await recordScanner.register(0); - } catch (err: any) { - expect(err).to.be.instanceOf(Error); - expect(err.message).to.equal("Account not set"); - failed = true; - } - expect(failed).to.be.true; - }); - it("should return EncryptedRecord[] after successfully getting encrypted records", async () => { - recordScanner = new RecordScanner({ url: "https://record-scanner.aleo.org", account: defaultAccount }); + recordScanner = new RecordScanner({ url: "https://record-scanner.aleo.org" }); const mockResponse = { ok: true, status: 200, @@ -178,17 +160,8 @@ describe("RecordScanner", () => { }); it("should return OwnedRecord[] after successfully getting owned records", async () => { - recordScanner = new RecordScanner({ url: "https://record-scanner.aleo.org", account: defaultAccount }); - const mockRegisterResponse = { - ok: true, - status: 201, - text: () => Promise.resolve('{"uuid": "test-uuid"}'), - json: () => Promise.resolve({ uuid: "test-uuid" }) - }; - fetchStub.resolves(mockRegisterResponse); - await recordScanner.register(0); - - fetchStub.resetHistory(); + recordScanner = new RecordScanner({ url: "https://record-scanner.aleo.org" }); + recordScanner.setUuid(defaultAccount.viewKey()); const mockResponse = { ok: true, @@ -235,17 +208,8 @@ describe("RecordScanner", () => { }); it("should return OwnedRecord after successfully getting owned record", async () => { - recordScanner = new RecordScanner({ url: "https://record-scanner.aleo.org", account: defaultAccount }); - const mockRegisterResponse = { - ok: true, - status: 201, - text: () => Promise.resolve('{"uuid": "test-uuid"}'), - json: () => Promise.resolve({ uuid: "test-uuid" }) - }; - fetchStub.resolves(mockRegisterResponse); - await recordScanner.register(0); - - fetchStub.resetHistory(); + recordScanner = new RecordScanner({ url: "https://record-scanner.aleo.org" }); + recordScanner.setUuid(defaultAccount.viewKey()); const mockResponse = { ok: true, @@ -262,7 +226,7 @@ describe("RecordScanner", () => { const request = fetchStub.firstCall.args[0] as Request; const body = await request.text(); const expectedBody = JSON.stringify({ - uuid: "test-uuid", + uuid: "7884164224800444110633570141944665301008802280502652120359195870264061098703field", }); expect(body).to.equal(expectedBody); expect(request.url).to.equal("https://record-scanner.aleo.org/records/owned"); @@ -271,7 +235,7 @@ describe("RecordScanner", () => { }); it("should throw an error if the uuid is not registered", async () => { - recordScanner = new RecordScanner({ url: "https://record-scanner.aleo.org", account: defaultAccount }); + recordScanner = new RecordScanner({ url: "https://record-scanner.aleo.org" }); let failed = false; try { await recordScanner.findRecords({ @@ -290,14 +254,14 @@ describe("RecordScanner", () => { }); } catch (err: any) { expect(err).to.be.instanceOf(Error); - expect(err.message).to.equal("Not registered"); + expect(err.message).to.equal("You are using the RecordScanner implementation of the RecordProvider. No account has been registered with the RecordScanner which is required to use the findRecords method. Please set an with the setAccount method before calling the findRecords method again."); failed = true; } expect(failed).to.be.true; }); it("should return record of string->boolean after successfully checking serial numbers", async () => { - recordScanner = new RecordScanner({ url: "https://record-scanner.aleo.org", account: defaultAccount }); + recordScanner = new RecordScanner({ url: "https://record-scanner.aleo.org" }); const mockResponse = { ok: true, status: 200, @@ -324,7 +288,7 @@ describe("RecordScanner", () => { }); it("should return record of string->boolean after successfully checking tags", async () => { - recordScanner = new RecordScanner({ url: "https://record-scanner.aleo.org", account: defaultAccount }); + recordScanner = new RecordScanner({ url: "https://record-scanner.aleo.org" }); const mockResponse = { ok: true, status: 200, @@ -353,7 +317,7 @@ describe("RecordScanner", () => { }); it("should return StatusResponse after successfully checking status", async () => { - recordScanner = new RecordScanner({ url: "https://record-scanner.aleo.org", account: defaultAccount }); + recordScanner = new RecordScanner({ url: "https://record-scanner.aleo.org" }); const mockResponse = { ok: true, status: 200, @@ -374,7 +338,7 @@ describe("RecordScanner", () => { }); it("should handle HTTP errors", async () => { - recordScanner = new RecordScanner({ url: "https://record-scanner.aleo.org", account: defaultAccount }); + recordScanner = new RecordScanner({ url: "https://record-scanner.aleo.org" }); let mockResponse = { ok: false, status: 500, @@ -384,7 +348,7 @@ describe("RecordScanner", () => { fetchStub.resolves(mockResponse); let failed = false; try { - await recordScanner.register(0); + await recordScanner.register(defaultAccount.viewKey(), 0); } catch (err: any) { expect(err).to.be.instanceOf(Error); expect(err.message).to.equal('{"error": "Internal server error"}'); @@ -401,7 +365,7 @@ describe("RecordScanner", () => { fetchStub.resolves(mockResponse); failed = false; try { - await recordScanner.register(0); + await recordScanner.register(defaultAccount.viewKey(), 0); } catch (err: any) { expect(err).to.be.instanceOf(Error); expect(err.message).to.equal('{"error": "Invalid view key"}'); @@ -412,7 +376,7 @@ describe("RecordScanner", () => { fetchStub.rejects(new Error("Unknown error")); failed = false; try { - await recordScanner.register(0); + await recordScanner.register(defaultAccount.viewKey(), 0); } catch (err: any) { expect(err).to.be.instanceOf(Error); expect(err.message).to.equal("Unknown error"); diff --git a/wasm/src/types/field.rs b/wasm/src/types/field.rs index 7b9daf25d..f47d65b24 100644 --- a/wasm/src/types/field.rs +++ b/wasm/src/types/field.rs @@ -117,7 +117,7 @@ impl Field { /// Initializes a new field as a domain separator. #[wasm_bindgen(js_name = "newDomainSeparator")] - pub fn new_domain_separator(&self, domain: &str) -> Field { + pub fn new_domain_separator(domain: &str) -> Field { let domain_native = FieldNative::new(::Field::from_bytes_le_mod_order(domain.as_bytes())); Field::from(domain_native) From 0a5b091d99516ad1efcd6a7d6b686a4ae1ab5253 Mon Sep 17 00:00:00 2001 From: Alex Pitsikoulis Date: Tue, 30 Sep 2025 16:13:47 -0700 Subject: [PATCH 09/11] added RecordScanner integration tests --- sdk/src/record-scanner.ts | 6 +- sdk/tests/record-scanner.integration.ts | 125 ++++++++++++++++++++++++ sdk/tests/record-scanner.test.ts | 5 +- 3 files changed, 131 insertions(+), 5 deletions(-) create mode 100644 sdk/tests/record-scanner.integration.ts diff --git a/sdk/src/record-scanner.ts b/sdk/src/record-scanner.ts index 0441e8e25..5a8d979ef 100644 --- a/sdk/src/record-scanner.ts +++ b/sdk/src/record-scanner.ts @@ -188,13 +188,13 @@ class RecordScanner implements RecordProvider { * @param {string} jobId The job id to check. * @returns {Promise} The status of the job. */ - async checkStatus(jobId: string): Promise { + async checkStatus(): Promise { try { const response = await this.request( new Request(`${this.url}/status`, { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify(jobId), + body: JSON.stringify(this.uuid?.toString()), }), ); @@ -347,7 +347,7 @@ class RecordScanner implements RecordProvider { } } - private computeUUID(vk: ViewKey): Field { + computeUUID(vk: ViewKey): Field { // Construct the material needed for the Poseidon oracle. const inputs = [Field.newDomainSeparator(RECORD_DOMAIN), vk.toField(), Field.one()] // Calculate the uuid. diff --git a/sdk/tests/record-scanner.integration.ts b/sdk/tests/record-scanner.integration.ts new file mode 100644 index 000000000..5870a4838 --- /dev/null +++ b/sdk/tests/record-scanner.integration.ts @@ -0,0 +1,125 @@ +import { expect } from "chai"; +import { ViewKey } from "../src/node"; +import { RecordScanner } from "../src/record-scanner"; + +describe("RecordScanner", () => { + const recordScannerUrl = process.env.RECORD_SCANNER_URL as string; + const apiKey = process.env.KONG_API_KEY as string; + const viewKeyStr = process.env.VIEW_KEY as string; + const viewKey = new ViewKey(viewKeyStr); + let recordScanner = new RecordScanner({ url: recordScannerUrl, apiKey }); + + it("should successfully register a view key", async () => { + let response = await recordScanner.register(viewKey, 0); + expect(response.uuid).to.equal(recordScanner.computeUUID(viewKey).toString()); + }); + + it("should successfully get owned records", async () => { + let response = await recordScanner.findRecords({ + filter: { + program: "credits.aleo", + records: ["credits"], + start: 0, + end: 10332941, + }, + response: { + block_height: true, + program_name: true, + record_name: true, + commitment: true, + } + }); + expect(response.length).to.equal(9); + for (const record of response) { + expect(record.program_name).to.equal("credits.aleo"); + expect(record.record_name).to.equal("credits"); + expect(record.block_height).to.be.greaterThan(0); + expect(record.block_height).to.be.lessThan(10332941); + expect(record.commitment).to.exist; + expect(record.output_index).to.not.exist; + } + + response = await recordScanner.findRecords({ + filter: { + program: "credits.aleo", + records: ["credits"], + start: 10000000, + end: 10332941, + }, + }); + expect(response.length).to.equal(3); + for (const record of response) { + expect(record.program_name).to.equal("credits.aleo"); + expect(record.record_name).to.equal("credits"); + expect(record.block_height).to.be.greaterThan(10000000); + expect(record.block_height).to.be.lessThan(10332941); + expect(record.commitment).to.exist; + expect(record.output_index).to.exist; + } + }); + + it("should successfully get encrypted records", async () => { + let response = await recordScanner.encryptedRecords({ + program: "credits.aleo", + records: ["credits"], + results_per_page: 50, + response: { + commitment: true, + block_height: true, + program_name: true, + record_name: true, + }, + }); + + expect(response.length).to.equal(50); + for (const record of response) { + expect(record.program_name).to.equal("credits.aleo"); + expect(record.record_name).to.equal("credits"); + expect(record.commitment).to.exist; + expect(record.block_height).to.exist; + expect(record.output_index).to.not.exist; + } + + response = await recordScanner.encryptedRecords({ + program: "credits.aleo", + records: ["credits"], + results_per_page: 30, + }); + expect(response.length).to.equal(30); + for (const record of response) { + expect(record.program_name).to.equal("credits.aleo"); + expect(record.record_name).to.equal("credits"); + expect(record.commitment).to.exist; + expect(record.block_height).to.exist; + expect(record.output_index).to.exist; + } + }); + + it("should successfully check serial numbers", async () => { + let response = await recordScanner.checkSerialNumbers([ + "2497968624879919117393326048350070098671407363450098197552864797993755823036field", + "1050894655374138905808887909092891940183499902306462627909572997011712750387field", + ]); + expect(response).to.deep.equal({ + "2497968624879919117393326048350070098671407363450098197552864797993755823036field": false, + "1050894655374138905808887909092891940183499902306462627909572997011712750387field": false, + }); + }); + + it("should successfully check tags", async () => { + let response = await recordScanner.checkTags([ + "2726311268578079710210289900019159614843633435399431654197596897028642765098field", + "448505083045691117285710413252063292683250969684463991322463606849073525242field", + ]); + expect(response).to.deep.equal({ + "2726311268578079710210289900019159614843633435399431654197596897028642765098field": false, + "448505083045691117285710413252063292683250969684463991322463606849073525242field": false, + }); + }); + + it("should successfully get a job status", async () => { + let response = await recordScanner.checkStatus(); + expect(response.synced).to.be.instanceOf(Boolean); + expect(response.percentage).to.be.instanceOf(Number); + }) +}); \ No newline at end of file diff --git a/sdk/tests/record-scanner.test.ts b/sdk/tests/record-scanner.test.ts index 94992b296..706164b78 100644 --- a/sdk/tests/record-scanner.test.ts +++ b/sdk/tests/record-scanner.test.ts @@ -324,13 +324,14 @@ describe("RecordScanner", () => { text: () => Promise.resolve(JSON.stringify({ synced: true, percentage: 100 })), json: () => Promise.resolve({ synced: true, percentage: 100 }), }; + recordScanner.setUuid(defaultAccount.viewKey()); fetchStub.resolves(mockResponse); - const statusResponse = await recordScanner.checkStatus("test-job-id"); + const statusResponse = await recordScanner.checkStatus(); expect(statusResponse).to.deep.equal({ synced: true, percentage: 100 }); const request = fetchStub.firstCall.args[0] as Request; const body = await request.text(); - const expectedBody = JSON.stringify("test-job-id"); + const expectedBody = JSON.stringify(recordScanner.computeUUID(defaultAccount.viewKey()).toString()); expect(body).to.equal(expectedBody); expect(request.url).to.equal("https://record-scanner.aleo.org/status"); expect(request.method).to.equal("POST"); From 41eab23ae748e6087e5d39d7bcc207e2c32880bd Mon Sep 17 00:00:00 2001 From: Alex Pitsikoulis Date: Wed, 1 Oct 2025 10:30:18 -0700 Subject: [PATCH 10/11] corrected outdated client ip for CI --- create-leo-app/template-node/index.js | 2 +- wasm/src/programs/snapshot_query.rs | 2 +- wasm/src/utilities/rest.rs | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/create-leo-app/template-node/index.js b/create-leo-app/template-node/index.js index 9eb2b2228..1dbd129b5 100644 --- a/create-leo-app/template-node/index.js +++ b/create-leo-app/template-node/index.js @@ -63,7 +63,7 @@ async function remoteProgramExecution(programName, functionName, inputs) { const keyProvider = new AleoKeyProvider(); keyProvider.useCache(true); - const programManager = new ProgramManager("http://34.168.156.3:3030", keyProvider); + const programManager = new ProgramManager("http://34.169.215.4:3030", keyProvider); const tx = await programManager.buildExecutionTransaction( { diff --git a/wasm/src/programs/snapshot_query.rs b/wasm/src/programs/snapshot_query.rs index 33e1e5ef4..03afc2656 100644 --- a/wasm/src/programs/snapshot_query.rs +++ b/wasm/src/programs/snapshot_query.rs @@ -271,7 +271,7 @@ mod tests { let commitments = vec![FieldNative::from_str(commitment_0).unwrap(), FieldNative::from_str(commitment_1).unwrap()]; let (height, state_paths) = - SnapshotQuery::snapshot_statepaths("http://34.168.156.3:3030", &commitments).await.unwrap(); + SnapshotQuery::snapshot_statepaths("http://34.169.215.4:3030", &commitments).await.unwrap(); assert_eq!(state_paths.len(), 2); let (commitment_0_field, state_path_0) = &state_paths[0]; let (commitment_1_field, state_path_1) = &state_paths[1]; diff --git a/wasm/src/utilities/rest.rs b/wasm/src/utilities/rest.rs index 337777e4e..eb2d8d1e5 100644 --- a/wasm/src/utilities/rest.rs +++ b/wasm/src/utilities/rest.rs @@ -96,7 +96,7 @@ mod tests { ) .unwrap(), ]; - let state_paths = get_statepaths_for_commitments("http://34.168.156.3:3030", &commitments).await.unwrap(); + let state_paths = get_statepaths_for_commitments("http://34.169.215.4:3030", &commitments).await.unwrap(); assert_eq!(state_paths.len(), 2); assert_eq!(state_paths[0].global_state_root(), state_paths[1].global_state_root()); } From 604a20723dbcc175927dda2a084edadd8b62d304 Mon Sep 17 00:00:00 2001 From: Alex Pitsikoulis Date: Wed, 1 Oct 2025 13:15:41 -0700 Subject: [PATCH 11/11] added .spec to record scanner integration test filename --- ...-scanner.integration.ts => record-scanner-integration.spec.ts} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename sdk/tests/{record-scanner.integration.ts => record-scanner-integration.spec.ts} (100%) diff --git a/sdk/tests/record-scanner.integration.ts b/sdk/tests/record-scanner-integration.spec.ts similarity index 100% rename from sdk/tests/record-scanner.integration.ts rename to sdk/tests/record-scanner-integration.spec.ts