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/sdk/src/browser.ts b/sdk/src/browser.ts index 47313d8a7..78c8e90dc 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() { @@ -126,6 +127,7 @@ export { PUBLIC_TRANSFER, PUBLIC_TRANSFER_AS_SIGNER, PUBLIC_TO_PRIVATE_TRANSFER, + RECORD_DOMAIN, VALID_TRANSFER_TYPES, } from "./constants.js"; @@ -177,6 +179,7 @@ export { RecordsFilter, RecordsResponseFilter, RecordProvider, + RecordScanner, RecordSearchParams, SolutionJSON, SolutionsJSON, 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/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..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 = { @@ -8,7 +8,7 @@ * status: "pending", * } */ -interface RegistrationResponse { +export interface RegistrationResponse { uuid: string, job_id?: string, status?: string 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 new file mode 100644 index 000000000..5a8d979ef --- /dev/null +++ b/sdk/src/record-scanner.ts @@ -0,0 +1,359 @@ +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 { RecordsFilter } from "./models/record-scanner/recordsFilter"; +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; + 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 { + readonly url: string; + private apiKey?: { header: string, value: string }; + private uuid?: Field; + + constructor(options: RecordScannerOptions) { + this.url = options.url; + this.apiKey = typeof options.apiKey === "string" ? { header: "X-Provable-API-Key", value: options.apiKey } : options.apiKey; + } + + /** + * 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; + } + + /** + * 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(viewKey: ViewKey, startBlock: number): Promise { + try { + let request: RegistrationRequest = { + view_key: viewKey.to_string(), + start: startBlock, + }; + + 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; + } + } + + /** + * 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(): Promise { + try { + const response = await this.request( + new Request(`${this.url}/status`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(this.uuid?.toString()), + }), + ); + + 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. + * + * @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("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?.toString(); + + 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, + filter: { + start: searchParameters.filter?.start ?? 0, + program: "credits.aleo", + record: "credits", + }, + uuid: this.uuid?.toString(), + }); + + 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(`No records found matching the supplied search filter:\n${JSON.stringify(searchParameters, null, 2)}`); + } + + 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, + filter: { + start: searchParameters.filter?.start ?? 0, + program: "credits.aleo", + record: "credits", + }, + uuid: this.uuid?.toString(), + }); + 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; + } + } + + 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/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-integration.spec.ts b/sdk/tests/record-scanner-integration.spec.ts new file mode 100644 index 000000000..5870a4838 --- /dev/null +++ b/sdk/tests/record-scanner-integration.spec.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 new file mode 100644 index 000000000..706164b78 --- /dev/null +++ b/sdk/tests/record-scanner.test.ts @@ -0,0 +1,388 @@ +import { Account } from "../src/account"; +import { CHECK_SNS_RESPONSE, CHECK_TAGS_RESPONSE, ENCRYPTED_RECORDS, OWNED_RECORDS } from "./data/records"; +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" }); + 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 api key as a string", async () => { + recordScanner = new RecordScanner({ url: "https://record-scanner.aleo.org", 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(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", 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(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" }); + + 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(defaultAccount.viewKey(), 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" }); + + 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(defaultAccount.viewKey(), 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 return EncryptedRecord[] after successfully getting encrypted records", async () => { + recordScanner = new RecordScanner({ url: "https://record-scanner.aleo.org" }); + 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.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"); + }); + + it("should return OwnedRecord[] after successfully getting owned records", async () => { + recordScanner = new RecordScanner({ url: "https://record-scanner.aleo.org" }); + recordScanner.setUuid(defaultAccount.viewKey()); + + 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.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"); + }); + + it("should return OwnedRecord after successfully getting owned record", async () => { + recordScanner = new RecordScanner({ url: "https://record-scanner.aleo.org" }); + recordScanner.setUuid(defaultAccount.viewKey()); + + 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: "7884164224800444110633570141944665301008802280502652120359195870264061098703field", + }); + 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"); + }); + + it("should throw an error if the uuid is not registered", async () => { + recordScanner = new RecordScanner({ url: "https://record-scanner.aleo.org" }); + 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("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" }); + 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.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"); + }); + + it("should return record of string->boolean after successfully checking tags", async () => { + recordScanner = new RecordScanner({ url: "https://record-scanner.aleo.org" }); + 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.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"); + }); + + it("should return StatusResponse after successfully checking status", async () => { + recordScanner = new RecordScanner({ url: "https://record-scanner.aleo.org" }); + const mockResponse = { + ok: true, + status: 200, + 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(); + 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(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"); + 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" }); + let mockResponse = { + ok: false, + status: 500, + text: () => Promise.resolve('{"error": "Internal server error"}'), + }; + + fetchStub.resolves(mockResponse); + let failed = false; + try { + await recordScanner.register(defaultAccount.viewKey(), 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(defaultAccount.viewKey(), 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(defaultAccount.viewKey(), 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 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/types/field.rs b/wasm/src/types/field.rs index 256dfb10a..f47d65b24 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(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)) 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()); }