diff --git a/src/common/ant-registry.ts b/src/common/ant-registry.ts new file mode 100644 index 00000000..ae2c2a95 --- /dev/null +++ b/src/common/ant-registry.ts @@ -0,0 +1,118 @@ +/** + * Copyright (C) 2022-2024 Permanent Data Solutions, Inc. All Rights Reserved. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +import { ANT_REGISTRY_ID } from '../constants.js'; +import { + AoANTRegistryRead, + AoANTRegistryWrite, + AoMessageResult, + AoSigner, + OptionalSigner, + ProcessConfiguration, + WithSigner, + isProcessConfiguration, + isProcessIdConfiguration, +} from '../types.js'; +import { createAoSigner } from '../utils/ao.js'; +import { AOProcess, InvalidContractConfigurationError } from './index.js'; + +export class ANTRegistry { + static init(): AoANTRegistryRead; + static init( + config: Required & { signer?: undefined }, + ): AoANTRegistryRead; + static init({ + signer, + ...config + }: WithSigner>): AoANTRegistryRead; + static init( + config?: OptionalSigner, + ): AoANTRegistryRead | AoANTRegistryWrite { + if (config && config.signer) { + const { signer, ...rest } = config; + return new AoANTRegistryWriteable({ + ...rest, + signer, + }); + } + return new AoANTRegistryReadable(config); + } +} + +export class AoANTRegistryReadable implements AoANTRegistryRead { + protected process: AOProcess; + + constructor(config?: ProcessConfiguration) { + if ( + config && + (isProcessIdConfiguration(config) || isProcessConfiguration(config)) + ) { + if (isProcessConfiguration(config)) { + this.process = config.process; + } else if (isProcessIdConfiguration(config)) { + this.process = new AOProcess({ + processId: config.processId, + }); + } else { + throw new InvalidContractConfigurationError(); + } + } else { + this.process = new AOProcess({ + processId: ANT_REGISTRY_ID, + }); + } + } + + // Should we rename this to "getANTsByAddress"? seems more clear, though not same as handler name + async accessControlList({ + address, + }: { + address: string; + }): Promise<{ Owned: string[]; Controlled: string[] }> { + return this.process.read({ + tags: [ + { name: 'Action', value: 'Access-Control-List' }, + { name: 'Address', value: address }, + ], + }); + } +} + +export class AoANTRegistryWriteable + extends AoANTRegistryReadable + implements AoANTRegistryWrite +{ + private signer: AoSigner; + + constructor({ signer, ...config }: WithSigner) { + super(config); + this.signer = createAoSigner(signer); + } + + async register({ + processId, + }: { + processId: string; + }): Promise { + return this.process.send({ + tags: [ + { name: 'Action', value: 'Register' }, + { name: 'Process-Id', value: processId }, + ], + signer: this.signer, + }); + } +} diff --git a/src/common/index.ts b/src/common/index.ts index 63a144b8..959e5ea1 100644 --- a/src/common/index.ts +++ b/src/common/index.ts @@ -17,6 +17,7 @@ export * from './error.js'; export * from './logger.js'; export * from './ant.js'; +export * from './ant-registry.js'; // ao export * from './io.js'; diff --git a/src/constants.ts b/src/constants.ts index 7b72e504..dd98981a 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -37,6 +37,8 @@ export const IO_DEVNET_PROCESS_ID = export const ioDevnetProcessId = IO_DEVNET_PROCESS_ID; export const IO_TESTNET_PROCESS_ID = 'agYcCFJtrMG6cqMuZfskIkFTGvUPddICmtQSBIoPdiA'; + +export const ANT_REGISTRY_ID = 'i_le_yKKPVstLTDSmkHRqf-wYphMnwB9OhleiTgMkWc'; export const MIO_PER_IO = 1_000_000; export const AOS_MODULE_ID = 'cbn0KKrBZH7hdNkNokuXLtGryrWM--PjSTBqIzw9Kkk'; export const ANT_LUA_ID = 'Flwio4Lr08g6s6uim6lEJNnVGD9ylvz0_aafvpiL8FI'; diff --git a/src/io.ts b/src/io.ts index ace08759..dbb197c5 100644 --- a/src/io.ts +++ b/src/io.ts @@ -325,6 +325,16 @@ export interface AoANTWrite extends AoANTRead { setName({ name }): Promise; } +export interface AoANTRegistryRead { + accessControlList(params: { + address: string; + }): Promise<{ Owned: string[]; Controlled: string[] }>; +} + +export interface AoANTRegistryWrite extends AoANTRegistryRead { + register(params: { processId: string }): Promise; +} + // AO Contract types export interface AoIOState { GatewayRegistry: Record; diff --git a/src/utils/ao.ts b/src/utils/ao.ts index 723a97d5..0c2d094b 100644 --- a/src/utils/ao.ts +++ b/src/utils/ao.ts @@ -21,6 +21,7 @@ import { defaultArweave } from '../common/arweave.js'; import { AOProcess } from '../common/index.js'; import { ANT_LUA_ID, + ANT_REGISTRY_ID, AOS_MODULE_ID, DEFAULT_SCHEDULER_ID, } from '../constants.js'; @@ -35,6 +36,7 @@ export async function spawnANT({ scheduler = DEFAULT_SCHEDULER_ID, state, stateContractTxId, + antRegistryId = ANT_REGISTRY_ID, }: { signer: AoSigner; module?: string; @@ -43,6 +45,7 @@ export async function spawnANT({ scheduler?: string; state?: ANTState; stateContractTxId?: string; + antRegistryId?: string; }): Promise { //TODO: cache locally and only fetch if not cached const luaString = (await defaultArweave.transactions.getData(luaCodeTxId, { @@ -54,6 +57,12 @@ export async function spawnANT({ module, scheduler, signer, + tags: [ + { + name: 'ANT-Registry-Id', + value: antRegistryId, + }, + ], }); const aosClient = new AOProcess({ @@ -124,7 +133,7 @@ export async function evolveANT({ export function createAoSigner(signer: ContractSigner): AoSigner { if (!('publicKey' in signer)) { - return createDataItemSigner(signer) as any; + return createDataItemSigner(signer) as AoSigner; } const aoSigner = async ({ data, tags, target, anchor }) => { diff --git a/src/utils/json.ts b/src/utils/json.ts index 0afbe79c..47d45d93 100644 --- a/src/utils/json.ts +++ b/src/utils/json.ts @@ -14,6 +14,8 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ + +// eslint-disable-next-line @typescript-eslint/no-explicit-any export function safeDecode(data: any): R { try { return JSON.parse(data); diff --git a/src/utils/processes.ts b/src/utils/processes.ts index fa7d0230..15b59d66 100644 --- a/src/utils/processes.ts +++ b/src/utils/processes.ts @@ -17,11 +17,13 @@ import { EventEmitter } from 'eventemitter3'; import { pLimit } from 'plimit-lit'; +import { ANTRegistry } from '../common/ant-registry.js'; import { ANT } from '../common/ant.js'; import { IO } from '../common/io.js'; import { ILogger, Logger } from '../common/logger.js'; import { IO_TESTNET_PROCESS_ID } from '../constants.js'; import { + AoANTRegistryRead, AoANTState, AoArNSNameData, AoIORead, @@ -29,47 +31,18 @@ import { WalletAddress, } from '../types.js'; +/** + * @beta This API is in beta and may change in the future. + */ export const getANTProcessesOwnedByWallet = async ({ address, - contract = IO.init({ - processId: IO_TESTNET_PROCESS_ID, - }), + registry = ANTRegistry.init(), }: { address: WalletAddress; - contract?: AoIORead; + registry?: AoANTRegistryRead; }): Promise => { - const throttle = pLimit(50); - // get the record names of the registry - TODO: this may need to be paginated - const records: Record = await fetchAllArNSRecords({ - contract: contract, - }); - const uniqueContractProcessIds = Object.values(records) - .filter((record) => record.processId !== undefined) - .map((record) => record.processId); - - // check the contract owner and controllers - const ownedOrControlledByWallet = await Promise.all( - uniqueContractProcessIds.map(async (processId) => - throttle(async () => { - const ant = ANT.init({ - processId, - }); - const { Owner, Controllers } = await ant.getState(); - - if (Owner === address || Controllers.includes(address)) { - return processId; - } - return; - }), - ), - ); - - if (ownedOrControlledByWallet.length === 0) { - return []; - } - - // TODO: insert gql query to find ANT processes owned by wallet given wallet not currently in the registry - return [...new Set(ownedOrControlledByWallet)] as string[]; + const res = await registry.accessControlList({ address }); + return [...new Set([...res.Owned, ...res.Controlled])]; }; function timeout(ms: number, promise) { @@ -118,9 +91,11 @@ export class ArNSEventEmitter extends EventEmitter { async fetchProcessesOwnedByWallet({ address, pageSize, + antRegistry = ANTRegistry.init(), }: { address: WalletAddress; pageSize?: number; + antRegistry?: AoANTRegistryRead; }) { const uniqueContractProcessIds: Record< string, @@ -129,7 +104,8 @@ export class ArNSEventEmitter extends EventEmitter { names: Record; } > = {}; - + const antIdRes = await antRegistry.accessControlList({ address }); + const antIds = new Set([...antIdRes.Owned, ...antIdRes.Controlled]); await timeout( this.timeoutMs, fetchAllArNSRecords({ contract: this.contract, emitter: this, pageSize }), @@ -142,19 +118,18 @@ export class ArNSEventEmitter extends EventEmitter { }); return {}; }) - .then((records) => { - if (!records) return; - Object.entries(records).forEach(([name, record]) => { - if (record.processId === undefined) { - return; - } - if (uniqueContractProcessIds[record.processId] === undefined) { - uniqueContractProcessIds[record.processId] = { - state: undefined, - names: {}, - }; + .then((records: Record) => { + Object.entries(records).forEach(([name, arnsRecord]) => { + if (antIds.has(arnsRecord.processId)) { + if (uniqueContractProcessIds[arnsRecord.processId] == undefined) { + uniqueContractProcessIds[arnsRecord.processId] = { + state: undefined, + names: {}, + }; + } + uniqueContractProcessIds[arnsRecord.processId].names[name] = + arnsRecord; } - uniqueContractProcessIds[record.processId].names[name] = record; }); }); @@ -220,6 +195,7 @@ export const fetchAllArNSRecords = async ({ do { const pageResult = await contract .getArNSRecords({ cursor, limit: pageSize }) + // eslint-disable-next-line @typescript-eslint/no-explicit-any .catch((e: any) => { logger?.error(`Error getting ArNS records`, { message: e?.message, diff --git a/tests/e2e/cjs/index.test.js b/tests/e2e/cjs/index.test.js index bd9b2f48..05ab78a3 100644 --- a/tests/e2e/cjs/index.test.js +++ b/tests/e2e/cjs/index.test.js @@ -4,7 +4,7 @@ const assert = require('node:assert/strict'); * Ensure that npm link has been ran prior to running these tests * (simply running npm run test:integration will ensure npm link is ran) */ -const { IO, ioDevnetProcessId } = require('@ar.io/sdk'); +const { IO, ioDevnetProcessId, ANTRegistry } = require('@ar.io/sdk'); const io = IO.init({ processId: ioDevnetProcessId, @@ -266,3 +266,14 @@ describe('IO', async () => { assert.ok(tokenCost); }); }); + +describe('ANTRegistry', async () => { + const registry = ANTRegistry.init(); + const address = '7waR8v4STuwPnTck1zFVkQqJh5K9q9Zik4Y5-5dV7nk'; + + it('should retrieve ids from registry', async () => { + const affiliatedAnts = await registry.accessControlList({ address }); + assert(Array.isArray(affiliatedAnts.Owned)); + assert(Array.isArray(affiliatedAnts.Controlled)); + }); +}); diff --git a/tests/e2e/esm/index.test.js b/tests/e2e/esm/index.test.js index 49399f6e..98feda2e 100644 --- a/tests/e2e/esm/index.test.js +++ b/tests/e2e/esm/index.test.js @@ -1,4 +1,4 @@ -import { IO, ioDevnetProcessId } from '@ar.io/sdk'; +import { ANTRegistry, IO, ioDevnetProcessId } from '@ar.io/sdk'; import { strict as assert } from 'node:assert'; import { describe, it } from 'node:test'; @@ -267,3 +267,14 @@ describe('IO', async () => { assert.ok(tokenCost); }); }); + +describe('ANTRegistry', async () => { + const registry = ANTRegistry.init(); + const address = '7waR8v4STuwPnTck1zFVkQqJh5K9q9Zik4Y5-5dV7nk'; + + it('should retrieve ids from registry', async () => { + const affiliatedAnts = await registry.accessControlList({ address }); + assert(Array.isArray(affiliatedAnts.Owned)); + assert(Array.isArray(affiliatedAnts.Controlled)); + }); +}); diff --git a/tests/e2e/web/src/App.test.tsx b/tests/e2e/web/src/App.test.tsx index 88267927..f93caaca 100644 --- a/tests/e2e/web/src/App.test.tsx +++ b/tests/e2e/web/src/App.test.tsx @@ -14,7 +14,7 @@ describe('ESM browser validation', () => { screen.getByTestId('load-info-result'); }, { - interval: 2000, + interval: 10000, timeout: 30000, }, ); @@ -23,4 +23,21 @@ describe('ESM browser validation', () => { // check the sdk loaded the data expect(result).toHaveTextContent('true'); }); + + it('should retrieve ids from registry', async () => { + await act(async () => render()); + + await waitFor( + () => { + screen.getByTestId('load-registry-result'); + }, + { + interval: 2000, + timeout: 30000, + }, + ); + + const result = screen.getByTestId('load-registry-result'); + expect(result).toHaveTextContent('true'); + }); }); diff --git a/tests/e2e/web/src/App.tsx b/tests/e2e/web/src/App.tsx index 2cbe28e1..005e85ce 100644 --- a/tests/e2e/web/src/App.tsx +++ b/tests/e2e/web/src/App.tsx @@ -1,4 +1,4 @@ -import { IO } from '@ar.io/sdk/web'; +import { ANTRegistry, IO } from '@ar.io/sdk/web'; import { useEffect, useState } from 'react'; import Markdown from 'react-markdown'; import remarkGfm from 'remark-gfm'; @@ -6,32 +6,58 @@ import remarkGfm from 'remark-gfm'; import './App.css'; const io = IO.init(); +const antRegistry = ANTRegistry.init(); function App() { const [contract, setContract] = useState('Loading...'); - const [success, setSuccess] = useState(false); + const [ants, setAnts] = useState('Loading...'); + const [ioContractSuccess, setIoContractSuccess] = useState(false); + const [antRegistrySuccess, setAntRegistrySuccess] = useState(false); const [loaded, setLoaded] = useState(false); useEffect(() => { io.getInfo() - .then((state) => { + .then((state: any) => { setContract(`\`\`\`json\n${JSON.stringify(state, null, 2)}`); - setSuccess(true); + setIoContractSuccess(true); }) - .catch((error) => { + .catch((error: any) => { console.error(error); - setSuccess(false); + setIoContractSuccess(false); setContract('Error loading contract state'); }) .finally(() => { setLoaded(true); }); + antRegistry + .accessControlList({ + address: '7waR8v4STuwPnTck1zFVkQqJh5K9q9Zik4Y5-5dV7nk', + }) + .then((affiliatedAnts: { Owned: string[]; Controlled: string[] }) => { + setAnts(`\`\`\`json\n${JSON.stringify(affiliatedAnts, null, 2)}`); + setAntRegistrySuccess(true); + }) + .catch((error: any) => { + console.error(error); + setAntRegistrySuccess(false); + setAnts('Error loading affiliated ants'); + }); }, []); return (
- {loaded &&
{`${success}`}
} +
+ {loaded && ( +
{`${ioContractSuccess}`}
+ )} + + {contract} + +
+ {loaded && ( +
{`${antRegistrySuccess}`}
+ )} - {contract} + {ants}
);