diff --git a/README.md b/README.md index 3952832..84d94a5 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,22 @@ # Circles SDK The Circles SDK is a TypeScript library designed to simplify the interaction with -the [circles-contracts](https://github.com/circlesUBI/circles-contracts). +the [Circles V1](https://github.com/circlesUBI/circles-contracts) as well as +with [Circles V2](https://github.com/aboutcircles/circles-contracts-v2). + +1. [Installation](#installation) +2. [Initialization](#initialization) +3. [Usage](#usage) + 1. [Sdk](#sdk) + 2. [Signup at Circles](#signup-at-circles) + 1. [Circles V1](#circles-v1) + 2. [Circles V2](#circles-v2) + 3. [Avatar](#avatar) + 4. [Circles Data](#data) + 1. [CirclesQuery](#circlequeryt) + 5. [Events](#events) + 1. [Event types](#event-types) +4. [Building from source](#building-from-source) ## Installation @@ -13,17 +28,31 @@ npm install @circles-sdk/sdk ## Initialization -Configure the Circles SDK with a Circles RPC and a Pathfinder endpoint. +### 1. Chain configuration -```typescript -import { ChainConfig } from "@circles-sdk/sdk"; +Configure the Circles SDK with a Circles RPC and a Pathfinder endpoint URL and the addresses of the +Circles V1 and V2 hubs. + +The below config can be used for the Chiado testnet. -const chainConfig: ChainConfig = { - circlesRpcUrl: 'https://rpc.aboutcircles.com', - pathfinderUrl: 'https://pathfinder.aboutcircles.com' +_NOTE_: The node at `circlesRpcUrl` must have +the [circles-nethermind-plugin](https://github.com/CirclesUBI/circles-nethermind-plugin) installed. + +```typescript +import { ChainConfig } from '@circles-sdk/sdk'; + +// Chiado testnet: +export const chainConfig: ChainConfig = { + pathfinderUrl: 'https://pathfinder.aboutcircles.com', + circlesRpcUrl: 'https://chiado-rpc.aboutcircles.com', + v1HubAddress: '0xdbf22d4e8962db3b2f1d9ff55be728a887e47710', + v2HubAddress: '0x2066CDA98F98397185483aaB26A89445addD6740', + migrationAddress: '0x2A545B54bb456A0189EbC53ed7090BfFc4a6Af94' }; ``` +### 2. Signer + Additionally, you need an ethers.js provider and a signer. Assuming you are using MetaMask: ```typescript @@ -45,39 +74,246 @@ const sdk = new Sdk(chainConfig, signer); ## Usage -### Avatar +### Sdk -For regular Circles interactions, use the Avatar class: +The `Sdk` class acts as entry point to the Circles SDK. It's main purpose is to provide +access to the `Avatar` and `CirclesData` classes. Additionally, it provides access to the +raw TypeChain v1 and v2 hub contract wrappers. + +The `Sdk` class implements the following interface: ```typescript -const avatar = await sdk.getAvatar("0x1234..."); +/** + * The SDK interface. + */ +interface SdkInterface { + /** + * The signer used to sign transactions (connected wallet e.g. MetaMask). + */ + signer: ethers.AbstractSigner; + /** + * The chain specific Circles configuration (contract addresses and rpc endpoints). + */ + chainConfig: ChainConfig; + /** + * A configured instance of the CirclesData class, an easy-to-use wrapper around + * the Circles RPC Query API. + */ + data: CirclesData; + /** + * An instance of the typechain generated Circles V1 Hub contract wrapper. + */ + v1Hub: HubV1; + /** + * An instance of the typechain generated Circles V2 Hub contract wrapper. + */ + v2Hub: HubV2; + /** + * An instance of the v1 Pathfinder client (necessary for transfers; only available on gnosis chain with v1 Circles at the moment). + */ + v1Pathfinder: Pathfinder; + /** + * Gets an Avatar instance by its address. Fails if the avatar is not signed up at Circles. + * @param avatarAddress The avatar's address. + * @returns The Avatar instance. + */ + getAvatar: (avatarAddress: string) => Promise; + /** + * Registers the connected wallet as a human avatar in Circles v1. + * @returns The Avatar instance. + */ + registerHuman: () => Promise; + /** + * Registers the connected wallet as a human avatar in Circles v2. + * @param cidV0 The CIDv0 of the avatar's ERC1155 token metadata. + */ + registerHumanV2: (cidV0: string) => Promise; + /** + * Registers the connected wallet as an organization avatar in Circles v1. + */ + registerOrganization: () => Promise; + /** + * Registers the connected wallet as an organization avatar in Circles v2. + * @param name The organization's name. + * @param cidV0 The CIDv0 of the organization's metadata. + */ + registerOrganizationV2: (name: string, cidV0: string) => Promise; + /** + * Registers the connected wallet as a group avatar in Circles v2. + * @param mint The address of the minting policy contract to use. + * @param name The group's name. + * @param symbol The group token's symbol. + * @param cidV0 The CIDv0 of the group token's metadata. + */ + registerGroupV2: (mint: string, name: string, symbol: string, cidV0: string) => Promise; + /** + * Migrates a v1 avatar and all its Circles holdings to v2. + * [[ Currently only works for human avatars. ]] + * @param avatar The avatar's address. + * @param cidV0 The CIDv0 of the avatar's ERC1155 token metadata. + */ + migrateAvatar: (avatar: string, cidV0: string) => Promise; +} ```` -The `getAvatar` method will throw an error if the address is not registered. -Use `sdk.data.getAvatarInfo` to check if an address is registered. +### Signup at Circles + +The Circles SDK provides various `register*`-methods to sign up for Circles V1 and V2. + +Note: _An account can only be signed up at Circles once. However, it is possible to migrate an +account that +signed up at v1 to a v2 account._ -If you want to sign the connected wallet up for Circles, use the `registerHuman` +#### Circles V1 + +If you want to sign the connected wallet up for Circles V1, use the `registerHuman` or `registerOrganization` methods. ```typescript -const human = await sdk.registerHuman(); -const organization = await sdk.registerOrganization(); +await sdk.registerHuman(); +await sdk.registerOrganization(); +``` + +#### Circles V2 + +If you want to sign the connected wallet up for Circles V2, use +the `registerHumanV2`, `registerOrganizationV2` or `registerGroupV2` method. + +```typescript +await sdk.registerHumanV2(); +await sdk.registerOrganizationV2(); +await sdk.registerGroupV2(); ``` -Note that a wallet can only be signed up for Circles once. Either as a human or as an organization. +### Avatar -The `Avatar` class provides the following methods: +After you successfully signed up for Circles, you can use the `getAvatar` method to get the +`Avatar` object for the connected wallet (or for any other address for that matter). -* __trust__: Trusts another avatar. Trusting an avatar means you're willing to accept Circles that - have been issued by this avatar. -* __untrust__: Revokes trust from another avatar. This means you will no longer accept Circles - issued by this avatar. -* __getMintableAmount__: Gets the amount available to mint via `personalMint`. -* __personalMint__: Mints the available Circles for the avatar. -* __transfer__: Transfers Circles to another avatar. -* __getTrustRelations__: Gets the current incoming and outgoing trust relations of the avatar. -* __getTotalBalance__: Gets the total balance of the avatar. -* __getTransactionHistory__: Gets the transaction history of the avatar. +```typescript +const signerAddress = await signer.getAddress(); +const avatar = await sdk.getAvatar(signerAddress); +```` + +The `getAvatar` method will throw an error if the address is not registered. +Use `sdk.data.getAvatarInfo` to check if an address is registered. + +The `Avatar` class implements the following interfaces: + +```typescript +/** + * An Avatar represents a user registered at Circles. + */ +export interface AvatarInterface { + /** + * The avatar's address. + */ + readonly address: string; + + /** + * Gets basic information about an avatar. + * This includes the signup timestamp, circles version, avatar type and token address. + * If the avatar is initialized and this field is `undefined`, the avatar is not signed up at Circles. + */ + readonly avatarInfo: AvatarRow | undefined; + + /** + * Calculates the maximum Circles amount that can be transferred to another avatar. + * + * NOTE: This operation can be long-running (minutes). + * + * @param to The address of the avatar to transfer to. + * @returns The maximum amount that can be transferred. + */ + getMaxTransferableAmount(to: string): Promise; + + /** + * Transfers Circles to another avatar. + * + * NOTE: This operation can be long-running (minutes). + * + * @param to The address of the avatar to transfer to. + * @param amount The amount to transfer. + */ + transfer(to: string, amount: bigint): Promise; + + /** + * Trusts another avatar. Trusting an avatar means you're willing to accept Circles that have been issued by this avatar. + * @param avatar The address of the avatar to trust. + */ + trust(avatar: string): Promise; + + /** + * Revokes trust from another avatar. This means you will no longer accept Circles issued by this avatar. + * @param avatar + */ + untrust(avatar: string): Promise; + + /** + * Gets the amount available to mint via `personalMint()`. + * @returns The amount available to mint or '0'. + */ + getMintableAmount(): Promise; + + /** + * Mints the available CRC for the avatar. + */ + personalMint(): Promise; + + /** + * Stops the avatar's token. This will prevent any future `personalMint()` calls. + */ + stop(): Promise; + + /** + * Gets all trust relations of the avatar. + */ + getTrustRelations(): Promise; + + /** + * Gets a paged query of the transaction history of the avatar. + * @param pageSize The maximum number of transactions per page. + */ + getTransactionHistory(pageSize: number): Promise>; + + /** + * Gets the avatar's total circles balance. + */ + getTotalBalance(): Promise; +} + +/** + * V2 avatars have additional capabilities that are described in this interface. + */ +export interface AvatarInterfaceV2 extends AvatarInterface { + /** + * Uses holdings of the avatar as collateral to mint new group tokens. + * @param group The group which is minting the tokens. + * @param collateral The addresses of the tokens used as collateral. + * @param amounts The amounts of the collateral tokens to use. + * @param data Additional data for the minting operation. + */ + groupMint(group: string, collateral: string[], amounts: bigint[], data: Uint8Array): Promise; + + /** + * Wraps ERC115 Circles into demurraged ERC20 Circles. + * @param amount The amount of ERC115 Circles to wrap. + */ + wrapDemurrageErc20(amount: bigint): Promise; + + /** + * Wraps inflation ERC20 Circles into demurraged ERC20 Circles. + * @param amount The amount of inflation ERC20 Circles to wrap. + */ + wrapInflationErc20(amount: bigint): Promise; + + /** + * Invites an address as human to Circles v2. + * @param avatar The avatar's avatar. + */ + inviteHuman(avatar: string): Promise; +} +``` ### Data @@ -90,21 +326,199 @@ const rpc = new CirclesRpc(chainConfig.circlesRpcUrl); const data = new CirclesData(rpc); ``` -The `CirclesData` class provides the following methods: +The `CirclesData` class implements the following interface: -* __getAvatarInfo__: Gets basic information about an avatar, including signup timestamp, Circles - version, avatar type, and token address/id. -* __getTotalBalance__: Gets the total balance of an avatar. -* __getTokenBalances__: Gets the detailed token balances of an avatar. -* __getTransactionHistory__: Gets the transaction history of an avatar. -* __getTrustRelations__: Gets the current incoming and outgoing trust relations of an address. +```typescript +interface CirclesDataInterface { + /** + * Gets basic information about an avatar. + * This includes the signup timestamp, circles version, avatar type and token address/id. + * @param avatar The address to check. + * @returns The avatar information or undefined if the address is not an avatar. + */ + getAvatarInfo(avatar: string): Promise; + + /** + * Gets the total CRC v1 balance of an address. + * @param avatar The address to get the CRC balance for. + * @param asTimeCircles Whether to return the balance as TimeCircles or not (default: true). + * @returns The total CRC balance (either as TC 'number' or as CRC in 'wei'). + */ + getTotalBalance(avatar: string, asTimeCircles: boolean): Promise; + + /** + * Gets the total CRC v2 balance of an address. + * @param avatar The address to get the CRC balance for. + * @param asTimeCircles Whether to return the balance as TimeCircles or not (default: true). + */ + getTotalBalanceV2(avatar: string, asTimeCircles: boolean): Promise; + + /** + * Gets the detailed CRC v1 token balances of an address. + * @param avatar The address to get the token balances for. + * @param asTimeCircles Whether to return the balances as TimeCircles or not (default: true). + */ + getTokenBalances(avatar: string, asTimeCircles: boolean): Promise; + + /** + * Gets the detailed CRC v2 token balances of an address. + * @param avatar The address to get the token balances for. + * @param asTimeCircles Whether to return the balances as TimeCircles or not (default: true). + */ + getTokenBalancesV2(avatar: string, asTimeCircles: boolean): Promise; + + /** + * Gets the transaction history of an address. + * This contains incoming/outgoing transactions and minting of CRC (in v1 and v2). + * @param avatar The address to get the transaction history for. + * @param pageSize The maximum number of transactions per page. + */ + getTransactionHistory(avatar: string, pageSize: number): CirclesQuery; + + /** + * Gets the current incoming and outgoing trust relations of an address (in v1 and v2). + * @param avatar The address to get the trust list for. + * @param pageSize The maximum number of trust relations per page. + */ + getTrustRelations(avatar: string, pageSize: number): CirclesQuery; + + /** + * Gets all trust relations of an avatar and groups mutual trust relations together. + * @param avatar The address to get the trust relations for. + */ + getAggregatedTrustRelations(avatar: string): Promise; + + /** + * Subscribes to Circles events. + * @param avatar The address to subscribe to events for. If not provided, subscribes to all events. + */ + subscribeToEvents(avatar?: string): Promise>; + + /** + * Gets the list of avatars that have invited the given avatar. + * @param avatar The address to get the invitations for. + * @param pageSize The maximum number of invitations per page. + */ + getInvitations(avatar: string, pageSize: number): CirclesQuery; + + /** + * Gets the avatar that invited the given avatar. + * @param avatar The address to get the inviter for. + */ + getInvitedBy(avatar: string): Promise; +} +``` If you need more control about the queried data, you can query the RPC directly. Please refer to the [circles-nethermind-plugin](https://github.com/CirclesUBI/circles-nethermind-plugin?tab=readme-ov-file#quickstart) docs for more information. +#### CircleQuery + +The `CirclesQuery` class is a wrapper around +the [Circles RPC query API](https://github.com/CirclesUBI/circles-nethermind-plugin?tab=readme-ov-file#circles_query). +It allows you to query data in a paged manner. + +Note: _The max. page size is 1000._ + +```typescript +const query = await sdk.data.getTransactionHistory(signerAddress, 25); +let pageNo = 0; +while (await query.queryNextPage()) { + const resultRows = query.currentPage?.results ?? []; + console.log(`Page ${pageNo++}: ${resultRows.length} results`); +} +``` + +The `CirclesData` class provides a decent selection of common queries already, +but you can also use the `CirclesQuery` class directly. See the [Circles RPC query API](https://github.com/CirclesUBI/circles-nethermind-plugin?tab=readme-ov-file#circles_query) +documentation for more information about the query capabilities. + +```typescript +const query = new CirclesQuery(this.rpc, { + namespace: 'CrcV2', + table: 'InviteHuman', + columns: [ + 'blockNumber', + 'transactionIndex', + 'logIndex', + 'timestamp', + 'transactionHash', + 'inviter', + 'invited' + ], + filter: [ + { + Type: 'FilterPredicate', + FilterType: 'Equals', + Column: 'inviter', + Value: signerAddress.toLowerCase() + } + ], + sortOrder: 'DESC', + limit: pageSize +}); +``` + +### Events + +You can use the `@circles-sdk/data` package to subscribe to Circles events: + +```typescript +// Subscribing without an avatar address will subscribe to all events (firehose style). +const allEvents = await sdk.data.subscribeToEvents(); +allEvents.subscribe((event) => { + console.log(event); +}); + +// Subscribing to events for a specific avatar. +const avatarEvents = await sdk.data.subscribeToEvents(signerAddress); +avatarEvents.subscribe((event) => { + console.log(event); +}); +``` + +Alternatively, you can use an `Avatar` instance to subscribe to events specific to that avatar: + +```typescript +const avatar = await sdk.getAvatar(signerAddress); +const avatarEvents = await avatar.subscribeToEvents(); +avatarEvents.subscribe((event) => { + console.log(event); +}); +``` + +#### Event types + +The `CirclesEvent` type is an union of all possible Circles events. +Please consult the source code for the fields of each event type. + +```typescript +export type CirclesEvent = + | CrcV1_HubTransfer + | CrcV1_Signup + | CrcV1_OrganizationSignup + | CrcV1_Trust + | CrcV1_Transfer + | CrcV2_InviteHuman + | CrcV2_PersonalMint + | CrcV2_RegisterGroup + | CrcV2_RegisterHuman + | CrcV2_RegisterOrganization + | CrcV2_Stopped + | CrcV2_Trust + | CrcV2_TransferSingle + | CrcV2_URI + | CrcV2_ApprovalForAll + | CrcV2_TransferBatch + | CrcV2_DiscountCost + | CrcV2_RegisterShortName + | CrcV2_UpdateMetadataDigest + | CrcV2_CidV0; +``` ## Building from source + ```shell git clone https://github.com/CirclesUBI/circles-sdk.git cd circles-sdk diff --git a/package-lock.json b/package-lock.json index e41dbae..77dd5a9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4237,7 +4237,7 @@ }, "packages/abi-v1": { "name": "@circles-sdk/abi-v1", - "version": "0.0.45-preview-4", + "version": "0.0.45-preview-8", "license": "ISC", "dependencies": { "ethers": "^6.11.1" @@ -4248,7 +4248,7 @@ }, "packages/abi-v2": { "name": "@circles-sdk/abi-v2", - "version": "0.0.45-preview-4", + "version": "0.0.45-preview-8", "license": "ISC", "dependencies": { "ethers": "^6.11.1" @@ -4259,10 +4259,10 @@ }, "packages/data": { "name": "@circles-sdk/data", - "version": "0.0.45-preview-4", + "version": "0.0.45-preview-8", "license": "ISC", "dependencies": { - "@circles-sdk/utils": "0.0.45-preview-4" + "@circles-sdk/utils": "0.0.45-preview-8" }, "devDependencies": { "typescript": "^5.3.3" @@ -4270,12 +4270,12 @@ }, "packages/sdk": { "name": "@circles-sdk/sdk", - "version": "0.0.45-preview-4", + "version": "0.0.45-preview-8", "license": "ISC", "dependencies": { - "@circles-sdk/abi-v1": "0.0.45-preview-4", - "@circles-sdk/abi-v2": "0.0.45-preview-4", - "@circles-sdk/data": "0.0.45-preview-4", + "@circles-sdk/abi-v1": "0.0.45-preview-8", + "@circles-sdk/abi-v2": "0.0.45-preview-8", + "@circles-sdk/data": "0.0.45-preview-8", "ethers": "^6.11.1", "multihashes": "^4.0.3" }, @@ -4285,7 +4285,7 @@ }, "packages/utils": { "name": "@circles-sdk/utils", - "version": "0.0.45-preview-4", + "version": "0.0.45-preview-8", "license": "ISC", "dependencies": { "bignumber.js": "^9.1.2" diff --git a/packages/abi-v1/package.json b/packages/abi-v1/package.json index 12185bf..08f9de9 100644 --- a/packages/abi-v1/package.json +++ b/packages/abi-v1/package.json @@ -1,6 +1,6 @@ { "name": "@circles-sdk/abi-v1", - "version": "0.0.45-preview-4", + "version": "0.0.45-preview-29", "description": "", "type": "module", "main": "./dist/index.js", diff --git a/packages/abi-v2/package.json b/packages/abi-v2/package.json index 118ba9b..8ca4c1f 100644 --- a/packages/abi-v2/package.json +++ b/packages/abi-v2/package.json @@ -1,6 +1,6 @@ { "name": "@circles-sdk/abi-v2", - "version": "0.0.45-preview-4", + "version": "0.0.45-preview-29", "description": "", "type": "module", "main": "./dist/index.js", diff --git a/packages/data/package.json b/packages/data/package.json index 051765e..bb38e35 100644 --- a/packages/data/package.json +++ b/packages/data/package.json @@ -1,6 +1,6 @@ { "name": "@circles-sdk/data", - "version": "0.0.45-preview-4", + "version": "0.0.45-preview-29", "description": "", "type": "module", "main": "./dist/index.js", @@ -17,7 +17,7 @@ "build": "rollup -c" }, "dependencies": { - "@circles-sdk/utils": "0.0.45-preview-4" + "@circles-sdk/utils": "0.0.45-preview-29" }, "keywords": [], "author": "", diff --git a/packages/data/src/circlesData.ts b/packages/data/src/circlesData.ts index c44e46b..db5d6c0 100644 --- a/packages/data/src/circlesData.ts +++ b/packages/data/src/circlesData.ts @@ -7,68 +7,12 @@ import { AvatarRow } from './rows/avatarRow'; import { crcToTc } from '@circles-sdk/utils'; import { ethers } from 'ethers'; import { TrustRelation, TrustRelationRow } from './rows/trustRelationRow'; +import { CirclesDataInterface } from './circlesDataInterface'; +import { Observable } from './observable'; +import { CirclesEvent } from './events/events'; +import { InvitationRow } from './rows/invitationRow'; -export interface ICirclesData { - /** - * Gets basic information about an avatar. - * This includes the signup timestamp, circles version, avatar type and token address/id. - * @param avatar The address to check. - * @returns The avatar information or undefined if the address is not an avatar. - */ - getAvatarInfo(avatar: string): Promise; - - /** - * Gets the total CRC v1 balance of an address. - * @param avatar The address to get the CRC balance for. - * @param asTimeCircles Whether to return the balance as TimeCircles or not (default: true). - * @returns The total CRC balance (either as TC 'number' or as CRC in 'wei'). - */ - getTotalBalance(avatar: string, asTimeCircles: boolean): Promise; - - /** - * Gets the total CRC v2 balance of an address. - * @param avatar The address to get the CRC balance for. - * @param asTimeCircles Whether to return the balance as TimeCircles or not (default: true). - */ - getTotalBalanceV2(avatar: string, asTimeCircles: boolean): Promise; - - /** - * Gets the detailed CRC v1 token balances of an address. - * @param avatar The address to get the token balances for. - * @param asTimeCircles Whether to return the balances as TimeCircles or not (default: true). - */ - getTokenBalances(avatar: string, asTimeCircles: boolean): Promise; - - /** - * Gets the detailed CRC v2 token balances of an address. - * @param avatar The address to get the token balances for. - * @param asTimeCircles Whether to return the balances as TimeCircles or not (default: true). - */ - getTokenBalancesV2(avatar: string, asTimeCircles: boolean): Promise; - - /** - * Gets the transaction history of an address. - * This contains incoming/outgoing transactions and minting of CRC (in v1 and v2). - * @param avatar The address to get the transaction history for. - * @param pageSize The maximum number of transactions per page. - */ - getTransactionHistory(avatar: string, pageSize: number): CirclesQuery; - - /** - * Gets the current incoming and outgoing trust relations of an address (in v1 and v2). - * @param avatar The address to get the trust list for. - * @param pageSize The maximum number of trust relations per page. - */ - getTrustRelations(avatar: string, pageSize: number): CirclesQuery; - - /** - * Gets all trust relations of an avatar and groups mutual trust relations together. - * @param avatar The address to get the trust relations for. - */ - getAggregatedTrustRelations(avatar: string): Promise -} - -export class CirclesData implements ICirclesData { +export class CirclesData implements CirclesDataInterface { readonly rpc: CirclesRpc; constructor(rpc: CirclesRpc) { @@ -221,9 +165,9 @@ export class CirclesData implements ICirclesData { }); } - async getAggregatedTrustRelations(avatar: string): Promise { + async getAggregatedTrustRelations(avatarAddress: string): Promise { const pageSize = 1000; - const trustsQuery = this.getTrustRelations(avatar, pageSize); + const trustsQuery = this.getTrustRelations(avatarAddress, pageSize); const trustListRows: TrustListRow[] = []; // Fetch all trust relations @@ -237,11 +181,11 @@ export class CirclesData implements ICirclesData { // Group trust list rows by truster and trustee const trustBucket: { [avatar: string]: TrustListRow[] } = {}; trustListRows.forEach(row => { - if (row.truster !== avatar) { + if (row.truster !== avatarAddress) { trustBucket[row.truster] = trustBucket[row.truster] || []; trustBucket[row.truster].push(row); } - if (row.trustee !== avatar) { + if (row.trustee !== avatarAddress) { trustBucket[row.trustee] = trustBucket[row.trustee] || []; trustBucket[row.trustee].push(row); } @@ -249,23 +193,23 @@ export class CirclesData implements ICirclesData { // Determine trust relations return Object.entries(trustBucket) - .filter(([avatar]) => avatar !== avatar) + .filter(([avatar]) => avatar !== avatarAddress) .map(([avatar, rows]) => { const maxTimestamp = Math.max(...rows.map(o => o.timestamp)); let relation: TrustRelation; if (rows.length === 2) { relation = 'mutuallyTrusts'; - } else if (rows[0].trustee === avatar) { + } else if (rows[0].trustee === avatarAddress) { relation = 'trustedBy'; - } else if (rows[0].truster === avatar) { + } else if (rows[0].truster === avatarAddress) { relation = 'trusts'; } else { throw new Error(`Unexpected trust list row. Couldn't determine trust relation.`); } return { - subjectAvatar: avatar, + subjectAvatar: avatarAddress, relation: relation, objectAvatar: avatar, timestamp: maxTimestamp @@ -301,14 +245,105 @@ export class CirclesData implements ICirclesData { Value: avatar.toLowerCase() } ], + sortOrder: 'ASC', + limit: 1000 + }); + + if (!await circlesQuery.queryNextPage()) { + return undefined; + } + + const result = circlesQuery.currentPage?.results ?? []; + let returnValue: AvatarRow | undefined = undefined; + + for (const avatarRow of result) { + if (returnValue === undefined) { + returnValue = avatarRow; + } + + if (avatarRow.version === 1) { + returnValue.hasV1 = true; + returnValue.v1Token = avatarRow.tokenId; + } else { + returnValue = { + ...returnValue, + ...avatarRow + }; + } + } + + return returnValue; + } + + /** + * Subscribes to Circles events. + * @param avatar The avatar to subscribe to. If not provided, all events are subscribed to. + */ + subscribeToEvents(avatar?: string): Promise> { + return this.rpc.subscribe(avatar); + } + + /** + * Gets the invitations sent by an avatar. + * @param avatar The avatar to get the invitations for. + * @param pageSize The maximum number of invitations per page. + * @returns A CirclesQuery object to fetch the invitations. + */ + getInvitations(avatar: string, pageSize: number): CirclesQuery { + return new CirclesQuery(this.rpc, { + namespace: 'CrcV2', + table: 'InviteHuman', + columns: [ + 'blockNumber', + 'transactionIndex', + 'logIndex', + 'timestamp', + 'transactionHash', + 'inviter', + 'invited' + ], + filter: [ + { + Type: 'FilterPredicate', + FilterType: 'Equals', + Column: 'inviter', + Value: avatar.toLowerCase() + } + ], + sortOrder: 'DESC', + limit: pageSize + }); + } + + /** + * Gets the avatar that invited the given avatar. + * @param avatar The address of the invited avatar. + * @returns The address of the inviting avatar or undefined if not found. + */ + async getInvitedBy(avatar: string): Promise { + const circlesQuery = new CirclesQuery(this.rpc, { + namespace: 'CrcV2', + table: 'InviteHuman', + columns: [ + 'inviter' + ], + filter: [ + { + Type: 'FilterPredicate', + FilterType: 'Equals', + Column: 'invited', + Value: avatar.toLowerCase() + } + ], sortOrder: 'DESC', limit: 1 }); - if (!await circlesQuery.queryNextPage()) { + const page = await circlesQuery.queryNextPage(); + if (!page) { return undefined; } - return circlesQuery.currentPage?.results[0]; + return circlesQuery.currentPage?.results[0].inviter; } } \ No newline at end of file diff --git a/packages/data/src/circlesDataInterface.ts b/packages/data/src/circlesDataInterface.ts new file mode 100644 index 0000000..178be19 --- /dev/null +++ b/packages/data/src/circlesDataInterface.ts @@ -0,0 +1,88 @@ +import { AvatarRow } from './rows/avatarRow'; +import { TokenBalanceRow } from './rows/tokenBalanceRow'; +import { CirclesQuery } from './pagedQuery/circlesQuery'; +import { TransactionHistoryRow } from './rows/transactionHistoryRow'; +import { TrustListRow } from './rows/trustListRow'; +import { TrustRelationRow } from './rows/trustRelationRow'; +import { Observable } from './observable'; +import { CirclesEvent } from './events/events'; +import { InvitationRow } from './rows/invitationRow'; + +export interface CirclesDataInterface { + /** + * Gets basic information about an avatar. + * This includes the signup timestamp, circles version, avatar type and token address/id. + * @param avatar The address to check. + * @returns The avatar information or undefined if the address is not an avatar. + */ + getAvatarInfo(avatar: string): Promise; + + /** + * Gets the total CRC v1 balance of an address. + * @param avatar The address to get the CRC balance for. + * @param asTimeCircles Whether to return the balance as TimeCircles or not (default: true). + * @returns The total CRC balance (either as TC 'number' or as CRC in 'wei'). + */ + getTotalBalance(avatar: string, asTimeCircles: boolean): Promise; + + /** + * Gets the total CRC v2 balance of an address. + * @param avatar The address to get the CRC balance for. + * @param asTimeCircles Whether to return the balance as TimeCircles or not (default: true). + */ + getTotalBalanceV2(avatar: string, asTimeCircles: boolean): Promise; + + /** + * Gets the detailed CRC v1 token balances of an address. + * @param avatar The address to get the token balances for. + * @param asTimeCircles Whether to return the balances as TimeCircles or not (default: true). + */ + getTokenBalances(avatar: string, asTimeCircles: boolean): Promise; + + /** + * Gets the detailed CRC v2 token balances of an address. + * @param avatar The address to get the token balances for. + * @param asTimeCircles Whether to return the balances as TimeCircles or not (default: true). + */ + getTokenBalancesV2(avatar: string, asTimeCircles: boolean): Promise; + + /** + * Gets the transaction history of an address. + * This contains incoming/outgoing transactions and minting of CRC (in v1 and v2). + * @param avatar The address to get the transaction history for. + * @param pageSize The maximum number of transactions per page. + */ + getTransactionHistory(avatar: string, pageSize: number): CirclesQuery; + + /** + * Gets the current incoming and outgoing trust relations of an address (in v1 and v2). + * @param avatar The address to get the trust list for. + * @param pageSize The maximum number of trust relations per page. + */ + getTrustRelations(avatar: string, pageSize: number): CirclesQuery; + + /** + * Gets all trust relations of an avatar and groups mutual trust relations together. + * @param avatar The address to get the trust relations for. + */ + getAggregatedTrustRelations(avatar: string): Promise; + + /** + * Subscribes to Circles events. + * @param avatar The address to subscribe to events for. If not provided, subscribes to all events. + */ + subscribeToEvents(avatar?: string): Promise>; + + /** + * Gets the list of avatars that have invited the given avatar. + * @param avatar The address to get the invitations for. + * @param pageSize The maximum number of invitations per page. + */ + getInvitations(avatar: string, pageSize: number): CirclesQuery; + + /** + * Gets the avatar that invited the given avatar. + * @param avatar The address to get the inviter for. + */ + getInvitedBy(avatar: string): Promise; +} \ No newline at end of file diff --git a/packages/data/src/circlesRpc.ts b/packages/data/src/circlesRpc.ts index fce68fc..b8a966c 100644 --- a/packages/data/src/circlesRpc.ts +++ b/packages/data/src/circlesRpc.ts @@ -1,10 +1,20 @@ import { JsonRpcRequest } from './rpcSchema/jsonRpcRequest'; import { JsonRpcResponse } from './rpcSchema/jsonRpcResponse'; +import { Observable } from './observable'; +import { CirclesEvent } from './events/events'; +import { parseRpcSubscriptionMessage } from './events/parser'; export class CirclesRpc { private readonly rpcUrl: string; private idCounter = 0; + private websocket: WebSocket | null = null; + private websocketConnected = false; + private pendingResponses: Record = {}; + private subscriptionListeners: { + [subscriptionId: string]: ((event: { event: string, values: Record }[]) => void)[] + } = {}; + constructor(rpcUrl: string) { this.rpcUrl = rpcUrl; } @@ -32,9 +42,93 @@ export class CirclesRpc { } return jsonResponse; } + + private connect() { + return new Promise((resolve, reject) => { + let wsUrl = this.rpcUrl.replace('http', 'ws'); + if (wsUrl.endsWith('/')) { + wsUrl += 'ws'; + } else { + wsUrl += '/ws'; + } + this.websocket = new WebSocket(wsUrl); + + this.websocket.onopen = () => { + resolve(); + }; + + this.websocket.onmessage = (event) => { + const message = JSON.parse(event.data); + const { id, method, params } = message; + + if (id !== undefined && this.pendingResponses[id]) { + this.pendingResponses[id].resolve(message); + delete this.pendingResponses[id]; + } + + if (method === 'eth_subscription' && params) { + const { subscription, result } = params; + if (this.subscriptionListeners[subscription]) { + this.subscriptionListeners[subscription].forEach(listener => listener(result)); + } + } + }; + this.websocket.onclose = () => { + this.websocketConnected = false; + }; + this.websocket.onerror = (error) => { + console.error('WebSocket error:', error); + reject(error); + }; + }); + } + + private sendMessage(method: string, params: Record, timeout = 5000): Promise { + if (!this.websocket || this.websocket.readyState !== WebSocket.OPEN) { + return Promise.reject('WebSocket is not connected'); + } + const id = this.idCounter++; + const message = { jsonrpc: '2.0', method, params, id }; + return new Promise((resolve, reject) => { + this.pendingResponses[id] = { resolve, reject }; + this.websocket!.send(JSON.stringify(message)); + + setTimeout(() => { + if (this.pendingResponses[id]) { + this.pendingResponses[id].reject('Request timed out'); + delete this.pendingResponses[id]; + } + }, timeout); + }); + } + + public async subscribe(address?: string): Promise> { + if (!this.websocketConnected) { + await this.connect(); + this.websocketConnected = true; + } + const observable = Observable.create(); + const subscriptionArgs = JSON.stringify(address ? { address } : {}); + const response = await this.sendMessage('eth_subscribe', ['circles', subscriptionArgs]); + const subscriptionId = response.result; + if (!this.subscriptionListeners[subscriptionId]) { + this.subscriptionListeners[subscriptionId] = []; + } + this.subscriptionListeners[subscriptionId].push((events) => { + parseRpcSubscriptionMessage(events).forEach(event => observable.emit(event)); + }); + + // TODO: Add unsubscribe method to observable + return observable.property; + } } export type CirclesQueryRpcResult = { columns: string[]; rows: any[][]; -}; \ No newline at end of file +}; + +export type RawWebsocketEvent = { + event: string; + values: Record; +} \ No newline at end of file diff --git a/packages/data/src/events/events.ts b/packages/data/src/events/events.ts new file mode 100644 index 0000000..5771f4a --- /dev/null +++ b/packages/data/src/events/events.ts @@ -0,0 +1,171 @@ +// Base event type +export type CirclesBaseEvent = { + $event: CirclesEventType, + blockNumber: number; + timestamp?: number; + transactionIndex: number; + logIndex: number; + transactionHash?: string; +}; + +// Event types +export type CrcV1_HubTransfer = CirclesBaseEvent & { + from?: string; + to?: string; + amount?: bigint; +}; + +export type CrcV1_Signup = CirclesBaseEvent & { + user?: string; + token?: string; +}; + +export type CrcV1_OrganizationSignup = CirclesBaseEvent & { + organization?: string; +}; + +export type CrcV1_Trust = CirclesBaseEvent & { + canSendTo?: string; + user?: string; + limit?: bigint; +}; + +export type CrcV1_Transfer = CirclesBaseEvent & { + tokenAddress?: string; + from?: string; + to?: string; + amount?: bigint; +}; + +export type CrcV2_InviteHuman = CirclesBaseEvent & { + inviter?: string; + invited?: string; +}; + +export type CrcV2_PersonalMint = CirclesBaseEvent & { + human?: string; + amount?: bigint; + startPeriod?: bigint; + endPeriod?: bigint; +}; + +export type CrcV2_RegisterGroup = CirclesBaseEvent & { + group?: string; + mint?: string; + treasury?: string; + name?: string; + symbol?: string; +}; + +export type CrcV2_RegisterHuman = CirclesBaseEvent & { + avatar?: string; +}; + +export type CrcV2_RegisterOrganization = CirclesBaseEvent & { + organization?: string; + name?: string; +}; + +export type CrcV2_Stopped = CirclesBaseEvent & { + avatar?: string; +}; + +export type CrcV2_Trust = CirclesBaseEvent & { + truster?: string; + trustee?: string; + expiryTime?: bigint; +}; + +export type CrcV2_TransferSingle = CirclesBaseEvent & { + operator?: string; + from?: string; + to?: string; + id?: bigint; + value?: bigint; +}; + +export type CrcV2_URI = CirclesBaseEvent & { + value?: string; + id?: bigint; +}; + +export type CrcV2_ApprovalForAll = CirclesBaseEvent & { + account?: string; + operator?: string; + approved?: boolean; +}; + +export type CrcV2_TransferBatch = CirclesBaseEvent & { + batchIndex: number; + operator?: string; + from?: string; + to?: string; + id?: bigint; + value?: bigint; +}; + +export type CrcV2_DiscountCost = CirclesBaseEvent & { + account?: string; + id?: bigint; + discountCost?: bigint; +}; + +export type CrcV2_RegisterShortName = CirclesBaseEvent & { + avatar?: string; + shortName?: bigint; + nonce?: bigint; +}; + +export type CrcV2_UpdateMetadataDigest = CirclesBaseEvent & { + avatar?: string; + metadataDigest?: Uint8Array; +}; + +export type CrcV2_CidV0 = CirclesBaseEvent & { + avatar?: string; + cidV0Digest?: Uint8Array; +}; + +export type CirclesEvent = + | CrcV1_HubTransfer + | CrcV1_Signup + | CrcV1_OrganizationSignup + | CrcV1_Trust + | CrcV1_Transfer + | CrcV2_InviteHuman + | CrcV2_PersonalMint + | CrcV2_RegisterGroup + | CrcV2_RegisterHuman + | CrcV2_RegisterOrganization + | CrcV2_Stopped + | CrcV2_Trust + | CrcV2_TransferSingle + | CrcV2_URI + | CrcV2_ApprovalForAll + | CrcV2_TransferBatch + | CrcV2_DiscountCost + | CrcV2_RegisterShortName + | CrcV2_UpdateMetadataDigest + | CrcV2_CidV0; + +export type CirclesEventType = + | 'CrcV1_HubTransfer' + | 'CrcV1_Signup' + | 'CrcV1_OrganizationSignup' + | 'CrcV1_Trust' + | 'CrcV1_Transfer' + | 'CrcV2_InviteHuman' + | 'CrcV2_PersonalMint' + | 'CrcV2_RegisterGroup' + | 'CrcV2_RegisterHuman' + | 'CrcV2_RegisterOrganization' + | 'CrcV2_Stopped' + | 'CrcV2_Trust' + | 'CrcV2_TransferSingle' + | 'CrcV2_URI' + | 'CrcV2_ApprovalForAll' + | 'CrcV2_TransferBatch' + | 'CrcV2_DiscountCost' + | 'CrcV2_RegisterShortName' + | 'CrcV2_UpdateMetadataDigest' + | 'CrcV2_CidV0'; diff --git a/packages/data/src/events/parser.ts b/packages/data/src/events/parser.ts new file mode 100644 index 0000000..b34c1b7 --- /dev/null +++ b/packages/data/src/events/parser.ts @@ -0,0 +1,178 @@ +import { CirclesEvent, CirclesEventType } from './events'; + +type EventValues = { + [key: string]: string; +}; + +type RpcSubscriptionMessage = Array<{ + event: string; + values: EventValues; +}>; + +const hexToBigInt = (hex: string): bigint => BigInt(hex); +const hexToNumber = (hex: string): number => parseInt(hex, 16); +const hexToUint8Array = (hex: string): Uint8Array => { + if (hex.length % 2 !== 0) throw new Error('Invalid hex string'); + const array = new Uint8Array(hex.length / 2); + for (let i = 0; i < hex.length; i += 2) { + array[i / 2] = parseInt(hex.substr(i, 2), 16); + } + return array; +}; + +const parseEventValues = (event: CirclesEventType, values: EventValues): CirclesEvent => { + const baseEvent = { + $event: event, + blockNumber: hexToNumber(values.blockNumber), + timestamp: values.timestamp ? hexToNumber(values.timestamp) : undefined, + transactionIndex: hexToNumber(values.transactionIndex), + logIndex: hexToNumber(values.logIndex), + transactionHash: values.transactionHash + }; + + switch (event) { + case 'CrcV1_HubTransfer': + return { + ...baseEvent, + from: values.from, + to: values.to, + amount: values.amount ? hexToBigInt(values.amount) : undefined + }; + case 'CrcV1_Signup': + return { + ...baseEvent, + user: values.user, + token: values.token + }; + case 'CrcV1_OrganizationSignup': + return { + ...baseEvent, + organization: values.organization + }; + case 'CrcV1_Trust': + return { + ...baseEvent, + canSendTo: values.canSendTo, + user: values.user, + limit: values.limit ? hexToBigInt(values.limit) : undefined + }; + case 'CrcV1_Transfer': + return { + ...baseEvent, + tokenAddress: values.tokenAddress, + from: values.from, + to: values.to, + amount: values.amount ? hexToBigInt(values.amount) : undefined + }; + case 'CrcV2_InviteHuman': + return { + ...baseEvent, + inviter: values.inviter, + invited: values.invited + }; + case 'CrcV2_PersonalMint': + return { + ...baseEvent, + human: values.human, + amount: values.amount ? hexToBigInt(values.amount) : undefined, + startPeriod: values.startPeriod ? hexToBigInt(values.startPeriod) : undefined, + endPeriod: values.endPeriod ? hexToBigInt(values.endPeriod) : undefined + }; + case 'CrcV2_RegisterGroup': + return { + ...baseEvent, + group: values.group, + mint: values.mint, + treasury: values.treasury, + name: values.name, + symbol: values.symbol + }; + case 'CrcV2_RegisterHuman': + return { + ...baseEvent, + avatar: values.avatar + }; + case 'CrcV2_RegisterOrganization': + return { + ...baseEvent, + organization: values.organization, + name: values.name + }; + case 'CrcV2_Stopped': + return { + ...baseEvent, + avatar: values.avatar + }; + case 'CrcV2_Trust': + return { + ...baseEvent, + truster: values.truster, + trustee: values.trustee, + expiryTime: values.expiryTime ? hexToBigInt(values.expiryTime) : undefined + }; + case 'CrcV2_TransferSingle': + return { + ...baseEvent, + operator: values.operator, + from: values.from, + to: values.to, + id: values.id ? hexToBigInt(values.id) : undefined, + value: values.value ? hexToBigInt(values.value) : undefined + }; + case 'CrcV2_URI': + return { + ...baseEvent, + value: values.value, + id: values.id ? hexToBigInt(values.id) : undefined + }; + case 'CrcV2_ApprovalForAll': + return { + ...baseEvent, + account: values.account, + operator: values.operator, + approved: values.approved === 'true' + }; + case 'CrcV2_TransferBatch': + return { + ...baseEvent, + batchIndex: hexToNumber(values.batchIndex), + operator: values.operator, + from: values.from, + to: values.to, + id: values.id ? hexToBigInt(values.id) : undefined, + value: values.value ? hexToBigInt(values.value) : undefined + }; + case 'CrcV2_DiscountCost': + return { + ...baseEvent, + account: values.account, + id: values.id ? hexToBigInt(values.id) : undefined, + discountCost: values.discountCost ? hexToBigInt(values.discountCost) : undefined + }; + case 'CrcV2_RegisterShortName': + return { + ...baseEvent, + avatar: values.avatar, + shortName: values.shortName ? hexToBigInt(values.shortName) : undefined, + nonce: values.nonce ? hexToBigInt(values.nonce) : undefined + }; + case 'CrcV2_UpdateMetadataDigest': + return { + ...baseEvent, + avatar: values.avatar, + metadataDigest: values.metadataDigest ? hexToUint8Array(values.metadataDigest) : undefined + }; + case 'CrcV2_CidV0': + return { + ...baseEvent, + avatar: values.avatar, + cidV0Digest: values.cidV0Digest ? hexToUint8Array(values.cidV0Digest) : undefined + }; + default: + throw new Error(`Unknown event type: ${event}`); + } +}; + +export const parseRpcSubscriptionMessage = (message: RpcSubscriptionMessage): CirclesEvent[] => { + return message.map(result => parseEventValues(result.event, result.values)); +}; \ No newline at end of file diff --git a/packages/data/src/index.ts b/packages/data/src/index.ts index 6eb2809..de48183 100644 --- a/packages/data/src/index.ts +++ b/packages/data/src/index.ts @@ -2,8 +2,8 @@ export { PagedQueryResult } from './pagedQuery/pagedQueryResult'; export { Cursor } from './pagedQuery/cursor'; export { CirclesQuery } from './pagedQuery/circlesQuery'; export { PagedQueryParams } from './pagedQuery/pagedQueryParams'; -export { Row } from './pagedQuery/row'; - +export { EventRow } from './pagedQuery/eventRow'; +export { Observable } from './observable'; export { CirclesQueryParams } from './rpcSchema/circlesQueryParams'; export { Conjunction } from './rpcSchema/conjunction'; export { Filter } from './rpcSchema/filter'; @@ -13,11 +13,37 @@ export { JsonRpcResponse } from './rpcSchema/jsonRpcResponse'; export { Order } from './rpcSchema/order'; export { SortOrder } from './rpcSchema/sortOrder'; export { Namespace, Table } from './rpcSchema/namespaces'; - -export { ICirclesData, CirclesData } from './circlesData'; +export { CirclesData } from './circlesData'; +export { CirclesDataInterface } from './circlesDataInterface'; export { TransactionHistoryRow } from './rows/transactionHistoryRow'; export { TrustListRow } from './rows/trustListRow'; export { TokenBalanceRow } from './rows/tokenBalanceRow'; export { AvatarRow } from './rows/avatarRow'; export { CirclesRpc } from './circlesRpc'; -export { TrustRelationRow } from './rows/trustRelationRow'; \ No newline at end of file +export { TrustRelationRow } from './rows/trustRelationRow'; +export { InvitationRow } from './rows/invitationRow'; +export { + CirclesEvent, + CirclesEventType, + CirclesBaseEvent, + CrcV1_HubTransfer, + CrcV2_CidV0, + CrcV1_Trust, + CrcV1_Transfer, + CrcV1_Signup, + CrcV1_OrganizationSignup, + CrcV2_ApprovalForAll, + CrcV2_RegisterOrganization, + CrcV2_RegisterShortName, + CrcV2_RegisterGroup, + CrcV2_RegisterHuman, + CrcV2_InviteHuman, + CrcV2_TransferBatch, + CrcV2_TransferSingle, + CrcV2_Trust, + CrcV2_DiscountCost, + CrcV2_Stopped, + CrcV2_URI, + CrcV2_PersonalMint, + CrcV2_UpdateMetadataDigest +} from './events/events'; \ No newline at end of file diff --git a/packages/sdk/src/observable.ts b/packages/data/src/observable.ts similarity index 100% rename from packages/sdk/src/observable.ts rename to packages/data/src/observable.ts diff --git a/packages/data/src/pagedQuery/circlesQuery.ts b/packages/data/src/pagedQuery/circlesQuery.ts index f680b69..593a9fa 100644 --- a/packages/data/src/pagedQuery/circlesQuery.ts +++ b/packages/data/src/pagedQuery/circlesQuery.ts @@ -5,7 +5,7 @@ import { JsonRpcResponse } from '../rpcSchema/jsonRpcResponse'; import { Filter } from '../rpcSchema/filter'; import { Order } from '../rpcSchema/order'; import { PagedQueryResult } from './pagedQueryResult'; -import { Row } from './row'; +import { EventRow } from './eventRow'; import { CirclesQueryRpcResult, CirclesRpc } from '../circlesRpc'; export class CalculatedColumn { @@ -26,7 +26,7 @@ export class CalculatedColumn { * * @typeParam TRow The type of the rows returned by the query. */ -export class CirclesQuery { +export class CirclesQuery { private readonly params: PagedQueryParams; private readonly rpc: CirclesRpc; diff --git a/packages/data/src/pagedQuery/row.ts b/packages/data/src/pagedQuery/eventRow.ts similarity index 88% rename from packages/data/src/pagedQuery/row.ts rename to packages/data/src/pagedQuery/eventRow.ts index d918968..d86da56 100644 --- a/packages/data/src/pagedQuery/row.ts +++ b/packages/data/src/pagedQuery/eventRow.ts @@ -2,7 +2,7 @@ * Defines the default and minimum columns, any row must have. * These values are important for pagination. */ -export interface Row { +export interface EventRow { blockNumber: number; transactionIndex: number; logIndex: number; diff --git a/packages/data/src/pagedQuery/pagedQueryResult.ts b/packages/data/src/pagedQuery/pagedQueryResult.ts index 5068c38..43fbc51 100644 --- a/packages/data/src/pagedQuery/pagedQueryResult.ts +++ b/packages/data/src/pagedQuery/pagedQueryResult.ts @@ -1,7 +1,7 @@ import { Cursor } from './cursor'; -import { Row } from './row'; +import { EventRow } from './eventRow'; -export interface PagedQueryResult { +export interface PagedQueryResult { /** * The number of results that were requested. */ diff --git a/packages/data/src/rows/avatarRow.ts b/packages/data/src/rows/avatarRow.ts index f803012..eb9ce11 100644 --- a/packages/data/src/rows/avatarRow.ts +++ b/packages/data/src/rows/avatarRow.ts @@ -1,10 +1,15 @@ -import { Row } from '../pagedQuery/row'; +import { EventRow } from '../pagedQuery/eventRow'; -export interface AvatarRow extends Row { +export interface AvatarRow extends EventRow { timestamp: number; transactionHash: string; version: number; type: string; avatar: string; tokenId: string; + hasV1: boolean; + v1Token: string; + + // Currently only set in avatar initialization + v1Stopped?: boolean; } \ No newline at end of file diff --git a/packages/data/src/rows/invitationRow.ts b/packages/data/src/rows/invitationRow.ts new file mode 100644 index 0000000..ab295ba --- /dev/null +++ b/packages/data/src/rows/invitationRow.ts @@ -0,0 +1,8 @@ +import { EventRow } from '../pagedQuery/eventRow'; + +export interface InvitationRow extends EventRow { + timestamp: number; + transactionHash: string; + inviter: string; + invited: string; +} \ No newline at end of file diff --git a/packages/data/src/rows/tokenBalanceRow.ts b/packages/data/src/rows/tokenBalanceRow.ts index 247e6aa..b3eb56a 100644 --- a/packages/data/src/rows/tokenBalanceRow.ts +++ b/packages/data/src/rows/tokenBalanceRow.ts @@ -1,6 +1,4 @@ -import { Row } from '../pagedQuery/row'; - -export interface TokenBalanceRow extends Row { +export interface TokenBalanceRow { token: string; balance: string; tokenOwner: string; diff --git a/packages/data/src/rows/transactionHistoryRow.ts b/packages/data/src/rows/transactionHistoryRow.ts index 67e3514..c76f5a3 100644 --- a/packages/data/src/rows/transactionHistoryRow.ts +++ b/packages/data/src/rows/transactionHistoryRow.ts @@ -1,6 +1,6 @@ -import { Row } from '../pagedQuery/row'; +import { EventRow } from '../pagedQuery/eventRow'; -export interface TransactionHistoryRow extends Row { +export interface TransactionHistoryRow extends EventRow { timestamp: number; transactionHash: string; version: number; diff --git a/packages/data/src/rows/trustListRow.ts b/packages/data/src/rows/trustListRow.ts index 9de33d0..f63400b 100644 --- a/packages/data/src/rows/trustListRow.ts +++ b/packages/data/src/rows/trustListRow.ts @@ -1,6 +1,6 @@ -import { Row } from '../pagedQuery/row'; +import { EventRow } from '../pagedQuery/eventRow'; -export interface TrustListRow extends Row { +export interface TrustListRow extends EventRow { timestamp: number; transactionHash: string; version: number; diff --git a/packages/sdk/package.json b/packages/sdk/package.json index 2d6be57..96163f1 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -1,6 +1,6 @@ { "name": "@circles-sdk/sdk", - "version": "0.0.45-preview-4", + "version": "0.0.45-preview-29", "description": "", "type": "module", "main": "./dist/index.js", @@ -17,9 +17,9 @@ "author": "", "license": "ISC", "dependencies": { - "@circles-sdk/abi-v1": "0.0.45-preview-4", - "@circles-sdk/abi-v2": "0.0.45-preview-4", - "@circles-sdk/data": "0.0.45-preview-4", + "@circles-sdk/abi-v1": "0.0.45-preview-29", + "@circles-sdk/abi-v2": "0.0.45-preview-29", + "@circles-sdk/data": "0.0.45-preview-29", "ethers": "^6.11.1", "multihashes": "^4.0.3" }, diff --git a/packages/sdk/src/Person.ts b/packages/sdk/src/AvatarInterface.ts similarity index 72% rename from packages/sdk/src/Person.ts rename to packages/sdk/src/AvatarInterface.ts index 68a1310..80206cb 100644 --- a/packages/sdk/src/Person.ts +++ b/packages/sdk/src/AvatarInterface.ts @@ -1,15 +1,13 @@ import { - AvatarRow, - CirclesQuery, - TransactionHistoryRow, + AvatarRow, CirclesQuery, TransactionHistoryRow, TrustRelationRow } from '@circles-sdk/data'; -import { ContractTransactionReceipt, TransactionReceipt } from 'ethers'; +import { ContractTransactionReceipt } from 'ethers'; /** * An Avatar represents a user registered at Circles. */ -export interface Person { +export interface AvatarInterface { /** * The avatar's address. */ @@ -22,12 +20,6 @@ export interface Person { */ readonly avatarInfo: AvatarRow | undefined; - /** - * A stream of events that have been caused by the avatar executing transactions. - */ - // TODO: re-implement events - // readonly events: Observable; - /** * Calculates the maximum Circles amount that can be transferred to another avatar. * @@ -93,10 +85,34 @@ export interface Person { getTotalBalance(): Promise; } -export interface PersonV2 extends Person { +/** + * V2 avatars have additional capabilities that are described in this interface. + */ +export interface AvatarInterfaceV2 extends AvatarInterface { + /** + * Uses holdings of the avatar as collateral to mint new group tokens. + * @param group The group which is minting the tokens. + * @param collateral The addresses of the tokens used as collateral. + * @param amounts The amounts of the collateral tokens to use. + * @param data Additional data for the minting operation. + */ groupMint(group: string, collateral: string[], amounts: bigint[], data: Uint8Array): Promise; + /** + * Wraps ERC115 Circles into demurraged ERC20 Circles. + * @param amount The amount of ERC115 Circles to wrap. + */ wrapDemurrageErc20(amount: bigint): Promise; + /** + * Wraps inflation ERC20 Circles into demurraged ERC20 Circles. + * @param amount The amount of inflation ERC20 Circles to wrap. + */ wrapInflationErc20(amount: bigint): Promise; + + /** + * Invites an address as human to Circles v2. + * @param avatar The avatar's avatar. + */ + inviteHuman(avatar: string): Promise; } \ No newline at end of file diff --git a/packages/sdk/src/avatar.ts b/packages/sdk/src/avatar.ts index ec839cc..50b1290 100644 --- a/packages/sdk/src/avatar.ts +++ b/packages/sdk/src/avatar.ts @@ -1,24 +1,21 @@ import { V1Person } from './v1/v1Person'; import { ContractTransactionReceipt, ContractTransactionResponse } from 'ethers'; import { Sdk } from './sdk'; -import { Person, PersonV2 } from './Person'; +import { AvatarInterface, AvatarInterfaceV2 } from './AvatarInterface'; import { AvatarRow, - CirclesQuery, + CirclesQuery, Observable, TransactionHistoryRow, TrustRelationRow } from '@circles-sdk/data'; import { V2Person } from './v2/v2Person'; - -// export type AvatarEvent = -// ParsedV1HubEvent -// | ParsedV1TokenEvent; +import { CirclesEvent } from '@circles-sdk/data'; /** * An Avatar represents a user registered at Circles. * It provides methods to interact with the Circles protocol, such as minting, transferring and trusting other avatars. */ -export class Avatar implements PersonV2 { +export class Avatar implements AvatarInterfaceV2 { public readonly address: string; @@ -26,13 +23,10 @@ export class Avatar implements PersonV2 { * The actual avatar implementation to use behind this facade. * @private */ - private _avatar: Person | undefined; + private _avatar: AvatarInterface | undefined; private _avatarInfo: AvatarRow | undefined; private _sdk: Sdk; - // public readonly events: Observable; - // private readonly emitEvent: (event: AvatarEvent) => void; - get avatarInfo(): AvatarRow | undefined { return this._avatarInfo; } @@ -42,14 +36,17 @@ export class Avatar implements PersonV2 { constructor(sdk: Sdk, avatarAddress: string) { this.address = avatarAddress.toLowerCase(); this._sdk = sdk; + } - // TODO: re-implement events - // const eventsProperty = Observable.create(); - // this.events = eventsProperty.property; - // this.emitEvent = eventsProperty.emit; - // sdk.v1Hub.events.subscribe(this.emitEvent); + public get events(): Observable { + if (!this._events) { + throw new Error('Not initialized'); + } + return this._events; } + private _events: Observable | undefined; + /** * Initializes the avatar. */ @@ -63,13 +60,34 @@ export class Avatar implements PersonV2 { throw new Error('Avatar is not signed up at Circles'); } - if (this._avatarInfo.version === 1) { - this._avatar = new V1Person(this._sdk, this._avatarInfo); - } else if (this._avatarInfo.version === 2) { - this._avatar = new V2Person(this._sdk, this._avatarInfo); - } else { - throw new Error('Unsupported avatar'); + const { version, hasV1 } = this._avatarInfo; + const v1Person = () => new V1Person(this._sdk, this._avatarInfo!); + const v2Person = () => new V2Person(this._sdk, this._avatarInfo!); + + switch (version) { + case 1: + this._avatar = v1Person(); + break; + + case 2: + if (!hasV1) { + this._avatar = v2Person(); + } else { + const v1Avatar = v1Person(); + const isStopped = await v1Avatar.v1Token?.stopped(); + this._avatar = isStopped ? v2Person() : v1Person(); + const avatarInfo = this._avatar.avatarInfo; + if (avatarInfo) { + avatarInfo.v1Stopped = isStopped; + } + } + break; + + default: + throw new Error('Unsupported avatar'); } + + this._events = await this._sdk.data.subscribeToEvents(this._avatarInfo.avatar); }; private onlyIfInitialized(func: () => T) { @@ -79,11 +97,11 @@ export class Avatar implements PersonV2 { return func(); } - private onlyIfV2(func: (avatar: PersonV2) => T) { + private onlyIfV2(func: (avatar: AvatarInterfaceV2) => T) { if (!this._avatar || this._avatarInfo?.version !== 2) { throw new Error('Avatar is not initialized or is not a v2 avatar'); } - return func(this._avatar); + return func(this._avatar); } getMintableAmount = (): Promise => this.onlyIfInitialized(() => this._avatar!.getMintableAmount()); @@ -99,4 +117,5 @@ export class Avatar implements PersonV2 { groupMint = (group: string, collateral: string[], amounts: bigint[], data: Uint8Array): Promise => this.onlyIfV2((avatar) => avatar.groupMint(group, collateral, amounts, data)); wrapDemurrageErc20 = (amount: bigint): Promise => this.onlyIfV2((avatar) => avatar.wrapDemurrageErc20(amount)); wrapInflationErc20 = (amount: bigint): Promise => this.onlyIfV2((avatar) => avatar.wrapInflationErc20(amount)); + inviteHuman = (avatar: string): Promise => this.onlyIfV2((_avatar) => _avatar.inviteHuman(avatar)); } \ No newline at end of file diff --git a/packages/sdk/src/errors.ts b/packages/sdk/src/errors.ts new file mode 100644 index 0000000..7c56528 --- /dev/null +++ b/packages/sdk/src/errors.ts @@ -0,0 +1,54 @@ +import { ethers } from 'ethers'; + +export const errorAbis = [ + 'error CirclesHubOnlyDuringBootstrap(uint8 code)', + 'error CirclesHubRegisterAvatarV1MustBeStopped(address avatar, uint8 code)', + 'error CirclesHubAvatarAlreadyRegistered(address avatar, uint8 code)', + 'error CirclesHubMustBeHuman(address avatar, uint8 code)', + 'error CirclesHubGroupIsNotRegistered(address group, uint8 code)', + 'error CirclesHubInvalidTrustReceiver(address trustReceiver, uint8 code)', + 'error CirclesHubGroupMintPolicyRejectedMint(address minter, address group, uint256[] collateral, uint256[] amounts, bytes data, uint8 code)', + 'error CirclesHubGroupMintPolicyRejectedBurn(address burner, address group, uint256 amount, bytes data, uint8 code)', + 'error CirclesHubOperatorNotApprovedForSource(address operator, address source, uint16 streamId, uint8 code)', + 'error CirclesHubFlowEdgeIsNotPermitted(address receiver, uint256 circlesId, uint8 code)', + 'error CirclesHubOnClosedPathOnlyPersonalCirclesCanReturnToAvatar(address failedReceiver, uint256 circlesId)', + 'error CirclesHubFlowVerticesMustBeSorted()', + 'error CirclesHubFlowEdgeStreamMismatch(uint16 flowEdgeId, uint16 streamId, uint8 code)', + 'error CirclesHubStreamMismatch(uint16 streamId, uint8 code)', + 'error CirclesHubNettedFlowMismatch(uint16 vertexPosition, int256 matrixNettedFlow, int256 streamNettedFlow)', + 'error CirclesERC1155MintBlocked(address human, address mintV1Status)', + 'error CirclesDemurrageAmountExceedsMaxUint190(address account, uint256 circlesId, uint256 amount, uint8 code)', + 'error CirclesDemurrageDayBeforeLastUpdatedDay(address account, uint256 circlesId, uint64 day, uint64 lastUpdatedDay, uint8 code)', + 'error CirclesERC1155CannotReceiveBatch(uint8 code)', + 'error CirclesAvatarMustBeRegistered(address avatar, uint8 code)', + 'error CirclesAddressCannotBeZero(uint8 code)', + 'error CirclesInvalidFunctionCaller(address caller, address expectedCaller, uint8 code)', + 'error CirclesInvalidCirclesId(uint256 id, uint8 code)', + 'error CirclesInvalidString(string str, uint8 code)', + 'error CirclesInvalidParameter(uint256 parameter, uint8 code)', + 'error CirclesAmountOverflow(uint256 amount, uint8 code)', + 'error CirclesArraysLengthMismatch(uint256 lengthArray1, uint256 lengthArray2, uint8 code)', + 'error CirclesArrayMustNotBeEmpty(uint8 code)', + 'error CirclesAmountMustNotBeZero(uint8 code)', + 'error CirclesProxyAlreadyInitialized()', + 'error CirclesLogicAssertion(uint8 code)', + 'error CirclesIdMustBeDerivedFromAddress(uint256 providedId, uint8 code)', + 'error CirclesReentrancyGuard(uint8 code)', + 'error CirclesStandardTreasuryGroupHasNoVault(address group)', + 'error CirclesStandardTreasuryRedemptionCollateralMismatch(uint256 circlesId, uint256[] redemptionIds, uint256[] redemptionValues, uint256[] burnIds, uint256[] burnValues)', + 'error CirclesStandardTreasuryInvalidMetadataType(bytes32 metadataType, uint8 code)', + 'error CirclesStandardTreasuryInvalidMetadata(bytes metadata, uint8 code)', + 'error CirclesNamesInvalidName(address avatar, string name, uint8 code)', + 'error CirclesNamesShortNameAlreadyAssigned(address avatar, uint72 shortName, uint8 code)', + 'error CirclesNamesShortNameWithNonceTaken(address avatar, uint256 nonce, uint72 shortName, address takenByAvatar)', + 'error CirclesNamesAvatarAlreadyHasCustomNameOrSymbol(address avatar, string nameOrSymbol, uint8 code)', + 'error CirclesNamesOrganizationHasNoSymbol(address organization, uint8 code)' +]; + +const iface = new ethers.Interface(errorAbis); + +export function parseError(errorData: string) { + const decodedError = iface.parseError(errorData); + console.log(decodedError); + throw new Error(JSON.stringify(decodedError)); +} \ No newline at end of file diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index bd461c0..eb8f6d9 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -1,7 +1,8 @@ export { Avatar } from './avatar'; -export { Observable } from './observable'; +export { Observable } from '@circles-sdk/data'; export { Sdk } from './sdk'; export { V1Person } from './v1/v1Person'; export { ChainConfig } from './chainConfig'; -export { AvatarRow, TrustListRow } from '@circles-sdk/data'; -export { Person } from './Person'; \ No newline at end of file +export { AvatarRow, TrustListRow, TrustRelationRow } from '@circles-sdk/data'; +export { AvatarInterface } from './AvatarInterface'; +export { parseError } from './errors'; \ No newline at end of file diff --git a/packages/sdk/src/sdk.ts b/packages/sdk/src/sdk.ts index bd1dffa..9343ccf 100644 --- a/packages/sdk/src/sdk.ts +++ b/packages/sdk/src/sdk.ts @@ -2,46 +2,121 @@ import { Avatar } from './avatar'; import { ethers } from 'ethers'; import { ChainConfig } from './chainConfig'; import { Pathfinder } from './v1/pathfinder'; -import { Person } from './Person'; -import { Hub as HubV1, Token__factory } from '@circles-sdk/abi-v1'; -import { Hub__factory as HubV1Factory } from '@circles-sdk/abi-v1'; -import { Hub as HubV2, Migration__factory } from '@circles-sdk/abi-v2'; -import { Hub__factory as HubV2Factory } from '@circles-sdk/abi-v2'; +import { AvatarInterface } from './AvatarInterface'; +import { Hub as HubV1, Hub__factory as HubV1Factory, Token__factory } from '@circles-sdk/abi-v1'; +import { + Hub as HubV2, + Hub__factory as HubV2Factory, + Migration__factory +} from '@circles-sdk/abi-v2'; import { AvatarRow, CirclesData, CirclesRpc } from '@circles-sdk/data'; +import multihashes from 'multihashes'; import { V1Person } from './v1/v1Person'; +/** + * The SDK interface. + */ +interface SdkInterface { + /** + * The signer used to sign transactions (connected wallet e.g. MetaMask). + */ + signer: ethers.AbstractSigner; + /** + * The chain specific Circles configuration (contract addresses and rpc endpoints). + */ + chainConfig: ChainConfig; + /** + * A configured instance of the CirclesData class, an easy-to-use wrapper around + * the Circles RPC Query API. + */ + data: CirclesData; + /** + * An instance of the typechain generated Circles V1 Hub contract wrapper. + */ + v1Hub: HubV1; + /** + * An instance of the typechain generated Circles V2 Hub contract wrapper. + */ + v2Hub: HubV2; + /** + * An instance of the v1 Pathfinder client (necessary for transfers; only available on gnosis chain with v1 Circles at the moment). + */ + v1Pathfinder: Pathfinder; + /** + * Gets an Avatar instance by its address. Fails if the avatar is not signed up at Circles. + * @param avatarAddress The avatar's address. + * @returns The Avatar instance. + */ + getAvatar: (avatarAddress: string) => Promise; + /** + * Registers the connected wallet as a human avatar in Circles v1. + * @returns The Avatar instance. + */ + registerHuman: () => Promise; + /** + * Registers the connected wallet as a human avatar in Circles v2. + * @param cidV0 The CIDv0 of the avatar's ERC1155 token metadata. + */ + registerHumanV2: (cidV0: string) => Promise; + /** + * Registers the connected wallet as an organization avatar in Circles v1. + */ + registerOrganization: () => Promise; + /** + * Registers the connected wallet as an organization avatar in Circles v2. + * @param name The organization's name. + * @param cidV0 The CIDv0 of the organization's metadata. + */ + registerOrganizationV2: (name: string, cidV0: string) => Promise; + /** + * Registers the connected wallet as a group avatar in Circles v2. + * @param mint The address of the minting policy contract to use. + * @param name The group's name. + * @param symbol The group token's symbol. + * @param cidV0 The CIDv0 of the group token's metadata. + */ + registerGroupV2: (mint: string, name: string, symbol: string, cidV0: string) => Promise; + /** + * Migrates a v1 avatar and all its Circles holdings to v2. + * [[ Currently only works for human avatars. ]] + * @param avatar The avatar's address. + * @param cidV0 The CIDv0 of the avatar's ERC1155 token metadata. + */ + migrateAvatar: (avatar: string, cidV0: string) => Promise; +} + /** * The SDK provides a high-level interface to interact with the Circles protocol. */ -export class Sdk { +export class Sdk implements SdkInterface { /** * The signer used to sign transactions. */ - public readonly signer: ethers.AbstractSigner; + readonly signer: ethers.AbstractSigner; /** * The chain specific Circles configuration. */ - public readonly chainConfig: ChainConfig; + readonly chainConfig: ChainConfig; /** * The Circles RPC client. */ - public readonly circlesRpc: CirclesRpc; + readonly circlesRpc: CirclesRpc; /** * The Circles data client. */ - public readonly data: CirclesData; + readonly data: CirclesData; /** * The V1 hub contract wrapper. */ - public readonly v1Hub: HubV1; + readonly v1Hub: HubV1; /** * The V2 hub contract wrapper. */ - public readonly v2Hub: HubV2; + readonly v2Hub: HubV2; /** * The pathfinder client. */ - public readonly v1Pathfinder: Pathfinder; + readonly v1Pathfinder: Pathfinder; /** * Creates a new SDK instance. @@ -76,7 +151,7 @@ export class Sdk { * Registers the connected wallet as a human avatar. * @returns The avatar instance. */ - registerHuman = async (): Promise => { + registerHuman = async (): Promise => { const receipt = await this.v1Hub.signup(); await receipt.wait(); @@ -86,9 +161,30 @@ export class Sdk { return this.getAvatar(signerAddress); }; - registerHumanV2 = async (metadataDigest: Uint8Array): Promise => { - const receipt = await this.v2Hub.registerHuman(metadataDigest); - await receipt.wait(); + cidV0Digest = (cidV0: string) => { + if (!cidV0.startsWith('Qm')) { + throw new Error('Invalid CID. Must be a CIDv0 with sha2-256 hash in base58 encoding'); + } + const cidBytes = multihashes.fromB58String(cidV0); + const decodedCid = multihashes.decode(cidBytes); + return decodedCid.digest; + }; + + registerHumanV2 = async (cidV0: string): Promise => { + const metadataDigest = this.cidV0Digest(cidV0); + // try { + const tx = await this.v2Hub.registerHuman(metadataDigest); + const receipt = await tx.wait(); + if (!receipt) { + throw new Error('Transaction failed'); + } + // } catch (e) { + // const revertData = (e).message.replace('Reverted ', ''); + // parseError(revertData); + // console.log('Caught error:'); + // console.error(e); + // throw e; + // } const signerAddress = await this.signer.getAddress(); await this.waitForAvatarInfo(signerAddress); @@ -100,7 +196,7 @@ export class Sdk { * Registers the connected wallet as an organization avatar. * @returns The avatar instance. */ - registerOrganization = async (): Promise => { + registerOrganization = async (): Promise => { const receipt = await this.v1Hub.organizationSignup(); await receipt.wait(); @@ -110,7 +206,8 @@ export class Sdk { return this.getAvatar(signerAddress); }; - registerOrganizationV2 = async (name: string, metadataDigest: Uint8Array): Promise => { + registerOrganizationV2 = async (name: string, cidV0: string): Promise => { + const metadataDigest = this.cidV0Digest(cidV0); const receipt = await this.v2Hub.registerOrganization(name, metadataDigest); await receipt.wait(); @@ -120,7 +217,8 @@ export class Sdk { return this.getAvatar(signerAddress); }; - registerGroupV2 = async (mint: string, name: string, symbol: string, metatdataDigest: Uint8Array): Promise => { + registerGroupV2 = async (mint: string, name: string, symbol: string, cidV0: string): Promise => { + const metatdataDigest = this.cidV0Digest(cidV0); const receipt = await this.v2Hub.registerGroup(mint, name, symbol, metatdataDigest); await receipt.wait(); @@ -146,20 +244,42 @@ export class Sdk { return avatarRow; }; - migrateAvatar = async (avatar: string, cidV0: Uint8Array): Promise => { + migrateAvatar = async (avatar: string, cidV0: string): Promise => { const avatarInfo = await this.data.getAvatarInfo(avatar); if (!avatarInfo) { throw new Error('Avatar not found'); } - if (avatarInfo.version != 1) { - throw new Error('Avatar is not a V1 avatar'); - } - const v1Avatar = new V1Person(this, avatarInfo); - const result = await v1Avatar.stop(); + if (avatarInfo.hasV1) { + // 1. Stop V1 token if necessary + if (avatarInfo.v1Token) { + const v1Avatar = new V1Person(this, avatarInfo); + const isStopped = await v1Avatar.v1Token?.stopped(); + + if (!isStopped) { + await v1Avatar.personalMint(); + const stopTx = await v1Avatar.v1Token?.stop(); + const stopTxReceipt = await stopTx?.wait(); + if (!stopTxReceipt) { + throw new Error('Failed to stop V1 avatar'); + } + } + } - await this.registerHumanV2(cidV0); - await this.migrateAllV1Tokens(avatar); + // 2. Signup V2 avatar if necessary + if (avatarInfo.version === 1) { + await this.registerHumanV2(cidV0); + } + + // 3. Make sure the v1 token minting status is known to the v2 hub + const calculateIssuanceTx = await this.v2Hub.calculateIssuanceWithCheck(avatar); + await calculateIssuanceTx.wait(); + + // 4. Migrate V1 tokens + await this.migrateAllV1Tokens(avatar); + } else { + throw new Error('Avatar is not a V1 avatar'); + } }; migrateAllV1Tokens = async (avatar: string): Promise => { diff --git a/packages/sdk/src/v1/v1Person.ts b/packages/sdk/src/v1/v1Person.ts index b300b4d..a270530 100644 --- a/packages/sdk/src/v1/v1Person.ts +++ b/packages/sdk/src/v1/v1Person.ts @@ -2,7 +2,7 @@ import { ContractTransactionReceipt } from 'ethers'; import { Sdk } from '../sdk'; -import { Person } from '../Person'; +import { AvatarInterface } from '../AvatarInterface'; import { Token, Token__factory } from '@circles-sdk/abi-v1'; import { AvatarRow, @@ -11,7 +11,7 @@ import { TrustRelationRow } from '@circles-sdk/data'; -export class V1Person implements Person { +export class V1Person implements AvatarInterface { public readonly sdk: Sdk; get address(): string { @@ -36,12 +36,12 @@ export class V1Person implements Person { this.sdk = sdk; this.avatarInfo = avatarInfo; - if (this.avatarInfo.version != 1) { + if (!this.avatarInfo.hasV1) { throw new Error('Avatar is not a v1 avatar'); } - if (this.avatarInfo.tokenId) { - this._v1Token = Token__factory.connect(this.avatarInfo.tokenId, this.sdk.signer); + if (this.avatarInfo.v1Token) { + this._v1Token = Token__factory.connect(this.avatarInfo.v1Token, this.sdk.signer); } } diff --git a/packages/sdk/src/v2/v2Person.ts b/packages/sdk/src/v2/v2Person.ts index 46416cd..2894a22 100644 --- a/packages/sdk/src/v2/v2Person.ts +++ b/packages/sdk/src/v2/v2Person.ts @@ -1,4 +1,4 @@ -import { PersonV2 } from '../Person'; +import { AvatarInterfaceV2 } from '../AvatarInterface'; import { ContractTransactionReceipt, ContractTransactionResponse } from 'ethers'; import { Sdk } from '../sdk'; import { @@ -19,7 +19,7 @@ export type Stream = { data: Uint8Array } -export class V2Person implements PersonV2 { +export class V2Person implements AvatarInterfaceV2 { public readonly sdk: Sdk; get address(): string { @@ -199,4 +199,18 @@ export class V2Person implements PersonV2 { wrapInflationErc20(amount: bigint): Promise { throw new Error('Not implemented'); } + + /** + * Invite a user to Circles (TODO: May cost you invite fees). + * @param avatar The address of the avatar to invite. Can be either a v1 address or an address that's not signed up yet. + */ + async inviteHuman(avatar: string): Promise { + const tx = await this.sdk.v2Hub.inviteHuman(avatar); + const receipt = await tx.wait(); + if (!receipt) { + throw new Error('Invite failed'); + } + + return receipt; + } } \ No newline at end of file diff --git a/packages/tests/package-lock.json b/packages/tests/package-lock.json index 3e20173..7cbe7f6 100644 --- a/packages/tests/package-lock.json +++ b/packages/tests/package-lock.json @@ -14,21 +14,21 @@ "@circles-sdk/sdk": "../sdk", "@circles/circles-contracts": "../circles-contracts", "@circles/circles-contracts-v2": "../circles-contracts-v2", - "ethers": "^6.11.1" + "ethers": "^6.11.1", + "multihashes": "^4.0.3" }, "devDependencies": { "@babel/preset-env": "^7.24.1", "@babel/preset-typescript": "^7.24.1", "@types/jest": "^29.5.11", "jest": "^29.7.0", - "multihashes": "^4.0.3", "ts-jest": "^29.1.2", "typescript": "^5.3.3" } }, "../abi-v1": { "name": "@circles-sdk/abi-v1", - "version": "0.0.45-preview-4", + "version": "0.0.45-preview-29", "license": "ISC", "dependencies": { "ethers": "^6.11.1" @@ -39,7 +39,7 @@ }, "../abi-v2": { "name": "@circles-sdk/abi-v2", - "version": "0.0.45-preview-4", + "version": "0.0.45-preview-29", "license": "ISC", "dependencies": { "ethers": "^6.11.1" @@ -84,10 +84,10 @@ }, "../data": { "name": "@circles-sdk/data", - "version": "0.0.45-preview-4", + "version": "0.0.45-preview-29", "license": "ISC", "dependencies": { - "@circles-sdk/utils": "0.0.45-preview-4" + "@circles-sdk/utils": "0.0.45-preview-29" }, "devDependencies": { "typescript": "^5.3.3" @@ -95,11 +95,12 @@ }, "../sdk": { "name": "@circles-sdk/sdk", - "version": "0.0.45-preview-4", + "version": "0.0.45-preview-29", "license": "ISC", "dependencies": { - "@circles-sdk/abi-v1": "0.0.45-preview-4", - "@circles-sdk/data": "0.0.45-preview-4", + "@circles-sdk/abi-v1": "0.0.45-preview-29", + "@circles-sdk/abi-v2": "0.0.45-preview-29", + "@circles-sdk/data": "0.0.45-preview-29", "ethers": "^6.11.1", "multihashes": "^4.0.3" }, @@ -2592,8 +2593,7 @@ "node_modules/@multiformats/base-x": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/@multiformats/base-x/-/base-x-4.0.1.tgz", - "integrity": "sha512-eMk0b9ReBbV23xXU693TAIrLyeO5iTgBZGSJfpqriG8UkYvr/hC9u9pyMlAakDNHWmbhMZCDs6KQO0jzKD8OTw==", - "dev": true + "integrity": "sha512-eMk0b9ReBbV23xXU693TAIrLyeO5iTgBZGSJfpqriG8UkYvr/hC9u9pyMlAakDNHWmbhMZCDs6KQO0jzKD8OTw==" }, "node_modules/@noble/curves": { "version": "1.2.0", @@ -5783,7 +5783,6 @@ "resolved": "https://registry.npmjs.org/multibase/-/multibase-4.0.6.tgz", "integrity": "sha512-x23pDe5+svdLz/k5JPGCVdfn7Q5mZVMBETiC+ORfO+sor9Sgs0smJzAjfTbM5tckeCqnaUuMYoz+k3RXMmJClQ==", "deprecated": "This module has been superseded by the multiformats module", - "dev": true, "dependencies": { "@multiformats/base-x": "^4.0.1" }, @@ -5795,14 +5794,12 @@ "node_modules/multiformats": { "version": "9.9.0", "resolved": "https://registry.npmjs.org/multiformats/-/multiformats-9.9.0.tgz", - "integrity": "sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg==", - "dev": true + "integrity": "sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg==" }, "node_modules/multihashes": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/multihashes/-/multihashes-4.0.3.tgz", "integrity": "sha512-0AhMH7Iu95XjDLxIeuCOOE4t9+vQZsACyKZ9Fxw2pcsRmlX4iCn1mby0hS0bb+nQOVpdQYWPpnyusw4da5RPhA==", - "dev": true, "dependencies": { "multibase": "^4.0.1", "uint8arrays": "^3.0.0", @@ -6555,7 +6552,6 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/uint8arrays/-/uint8arrays-3.1.1.tgz", "integrity": "sha512-+QJa8QRnbdXVpHYjLoTpJIdCTiw9Ir62nocClWuXIq2JIh4Uta0cQsTSpFL678p2CN8B+XSApwcU+pQEqVpKWg==", - "dev": true, "dependencies": { "multiformats": "^9.4.2" } @@ -6647,8 +6643,7 @@ "node_modules/varint": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/varint/-/varint-5.0.2.tgz", - "integrity": "sha512-lKxKYG6H03yCZUpAGOPOsMcGxd1RHCu1iKvEHYDPmTyq2HueGhD73ssNBqqQWfvYs04G9iUFRvmAVLW20Jw6ow==", - "dev": true + "integrity": "sha512-lKxKYG6H03yCZUpAGOPOsMcGxd1RHCu1iKvEHYDPmTyq2HueGhD73ssNBqqQWfvYs04G9iUFRvmAVLW20Jw6ow==" }, "node_modules/walker": { "version": "1.0.8", diff --git a/packages/tests/package.json b/packages/tests/package.json index b55e199..d369a36 100644 --- a/packages/tests/package.json +++ b/packages/tests/package.json @@ -11,16 +11,14 @@ "@circles-sdk/abi-v2": "../abi-v2", "@circles-sdk/sdk": "../sdk", "@circles-sdk/data": "../data", - "@circles/circles-contracts": "../circles-contracts", - "@circles/circles-contracts-v2": "../circles-contracts-v2", - "ethers": "^6.11.1" + "ethers": "^6.11.1", + "multihashes": "^4.0.3" }, "devDependencies": { "@babel/preset-env": "^7.24.1", "@babel/preset-typescript": "^7.24.1", "@types/jest": "^29.5.11", "jest": "^29.7.0", - "multihashes": "^4.0.3", "ts-jest": "^29.1.2", "typescript": "^5.3.3" } diff --git a/packages/tests/test/data/circlesData.test.ts b/packages/tests/test/data/circlesData.test.ts index cf81321..145a42e 100644 --- a/packages/tests/test/data/circlesData.test.ts +++ b/packages/tests/test/data/circlesData.test.ts @@ -5,7 +5,7 @@ import { CirclesRpc } from '@circles-sdk/data'; // - V1_HUB_ADDRESS=0xdbf22d4e8962db3b2f1d9ff55be728a887e47710 // - V2_HUB_ADDRESS=0xFFfbD3E62203B888bb8E09c1fcAcE58242674964 // - V2_NAME_REGISTRY_ADDRESS=0x0A1D308a39A6dF8972A972E586E4b4b3Dc73520f -const circlesRpc = `http://localhost:8545`; +const circlesRpc = `https://rpc.falkenstein.aboutcircles.com`; const rpc = new CirclesRpc(circlesRpc); describe('CirclesData', () => { @@ -120,4 +120,25 @@ describe('CirclesData', () => { console.log(JSON.stringify(trustRelationsQuery.currentPage?.results, null, 2)); }); + + it('should get the aggregate trust relations of an address', async () => { + const circlesData = new CirclesData(rpc); + + const trustRelations = await circlesData.getAggregatedTrustRelations('0xed31ba919d6b836a6efe3f8225f6f79e71fb3b38'); + expect(trustRelations).toBeDefined(); + }); + + it('should subscribe to Circles events', async () => { + const circlesData = new CirclesData(rpc); + + const eventsObservable = await circlesData.subscribeToEvents('0xed31ba919d6b836a6efe3f8225f6f79e71fb3b38'); + expect(eventsObservable).toBeDefined(); + + eventsObservable.subscribe(event => { + console.log(event); + }); + + // Wait for events + await new Promise(resolve => setTimeout(resolve, 60000)); + }); }); \ No newline at end of file diff --git a/packages/tests/test/data/circlesQuery.test.ts b/packages/tests/test/data/circlesQuery.test.ts index 159c3b8..f24f324 100644 --- a/packages/tests/test/data/circlesQuery.test.ts +++ b/packages/tests/test/data/circlesQuery.test.ts @@ -6,7 +6,7 @@ import { CirclesRpc } from '@circles-sdk/data'; // - V1_HUB_ADDRESS=0xdbf22d4e8962db3b2f1d9ff55be728a887e47710 // - V2_HUB_ADDRESS=0xFFfbD3E62203B888bb8E09c1fcAcE58242674964 // - V2_NAME_REGISTRY_ADDRESS=0x0A1D308a39A6dF8972A972E586E4b4b3Dc73520f -const circlesRpc = `http://localhost:8545`; +const circlesRpc = `https://rpc.falkenstein.aboutcircles.com`; const rpc = new CirclesRpc(circlesRpc); describe('CirclesQuery', () => { diff --git a/packages/tests/test/sdk/v1/v1Avatar.test.ts b/packages/tests/test/sdk/v1/v1Avatar.test.ts index d5a2b81..7308858 100644 --- a/packages/tests/test/sdk/v1/v1Avatar.test.ts +++ b/packages/tests/test/sdk/v1/v1Avatar.test.ts @@ -1,17 +1,22 @@ import { ChainConfig, Sdk } from '@circles-sdk/sdk'; import { ethers } from 'ethers'; +import { parseError } from '@circles-sdk/sdk'; describe('V1Avatar', () => { const chainConfig: ChainConfig = { - // migrationAddress: '0x0A1D308a39A6dF8972A972E586E4b4b3Dc73520f', - circlesRpcUrl: 'http://localhost:8545', + migrationAddress: '0x0A1D308a39A6dF8972A972E586E4b4b3Dc73520f', + circlesRpcUrl: 'https://chiado-rpc.aboutcircles.com', pathfinderUrl: 'https://pathfinder.aboutcircles.com', v1HubAddress: '0xdbf22d4e8962db3b2f1d9ff55be728a887e47710', - // v2HubAddress: '0xFFfbD3E62203B888bb8E09c1fcAcE58242674964' + v2HubAddress: '0xFFfbD3E62203B888bb8E09c1fcAcE58242674964' }; describe('initialize', () => { it('should initialize the avatar', async () => { + + const error = "0x071335d8000000000000000000000000b49a7bccd607ef482b71988a11f65fece980eca50000000000000000000000004f24c2cd960d44f76b79f963706602872205db8b"; + parseError(error); + const wallet = ethers.Wallet.createRandom(); const sdk = new Sdk(chainConfig, wallet); const avatar = await sdk.getAvatar('0xD68193591d47740E51dFBc410da607A351b56586'); diff --git a/packages/utils/package.json b/packages/utils/package.json index b276930..dafc51b 100644 --- a/packages/utils/package.json +++ b/packages/utils/package.json @@ -1,6 +1,6 @@ { "name": "@circles-sdk/utils", - "version": "0.0.45-preview-4", + "version": "0.0.45-preview-29", "description": "", "type": "module", "main": "./dist/index.js",