diff --git a/nft_mirror/oracle_server/.env.sample b/nft_mirror/oracle_server/.env.sample index 2b04bf3d62b44..dc9a69172fc28 100644 --- a/nft_mirror/oracle_server/.env.sample +++ b/nft_mirror/oracle_server/.env.sample @@ -9,3 +9,11 @@ ALCHEMY_API_KEY="demo" # Sui Gateway endpoint SUI_GATEWAY_ENDPOINT = "http://127.0.0.1:5000" + +# Oracle settings +ORACLE_ADDRESS = "89d470bf29a4e80ebbc868813dc7effc27bf0ea0" +ORACLE_CONTRACT_PACKAGE = "0x2" +ORACLE_CONTRACT_MODULE = "CrossChainAirdrop" +ORACLE_CONTRACT_ENTRY_FUNCTION = "claim" +ORACLE_CONTRACT_ADMIN_IDENTIFIER = "CrossChainAirdropOracle" +ORACLE_CONTRACT_NFT_IDENTIFIER = "NFT" \ No newline at end of file diff --git a/nft_mirror/oracle_server/.eslintrc.json b/nft_mirror/oracle_server/.eslintrc.json index 3457c793c0a26..18be3fa3034ec 100644 --- a/nft_mirror/oracle_server/.eslintrc.json +++ b/nft_mirror/oracle_server/.eslintrc.json @@ -10,5 +10,8 @@ ], "rules": { "prettier/prettier": 2 // Means error - } + }, + "ignorePatterns": [ + "**/gateway-generated-schema.ts" + ] } diff --git a/nft_mirror/oracle_server/src/airdrop/airdropService.ts b/nft_mirror/oracle_server/src/airdrop/airdropService.ts index 6c355278e24cc..6f72149bf2d16 100644 --- a/nft_mirror/oracle_server/src/airdrop/airdropService.ts +++ b/nft_mirror/oracle_server/src/airdrop/airdropService.ts @@ -2,6 +2,9 @@ // SPDX-License-Identifier: Apache-2.0 import { ValidateError } from 'tsoa'; +import { Connection } from '../sdk/gateway'; + +const DEFAULT_GAS_BUDGET = 2000; interface AirdropClaimInfo { /** @@ -86,6 +89,11 @@ export class AirdropService { const { wallet_message } = claimMessage; const data = JSON.parse(wallet_message); const claimInfo = this.parseClaimInfo(data); + // TODO: validate signature and ownership + const connection = new Connection( + process.env.SUI_GATEWAY_ENDPOINT as string + ); + await this.executeMoveCall(connection, claimInfo); return { source_chain: claimInfo.source_chain, source_contract_address: claimInfo.source_contract_address, @@ -105,6 +113,90 @@ export class AirdropService { 'Wrong format for wallet message' ); } + + async executeMoveCall( + connection: Connection, + claimInfo: AirdropClaimInfo + ): Promise { + const oracleAddress = process.env.ORACLE_ADDRESS as string; + const [gasObjectId, oracleObjectId] = await this.getGasAndOracle( + connection, + oracleAddress + ); + const { + destination_sui_address, + source_contract_address, + source_token_id, + } = claimInfo; + + // TODO: remove these hardcoded values in the next PR + const name = 'BoredApeYachtClub'; + const tokenUri = 'ipfs://abc'; + + const args = [ + oracleObjectId, + destination_sui_address, + source_contract_address, + +source_token_id, + name, + tokenUri, + ]; + + const [packageObjectId, module] = this.getPackageAndModule(); + + const request = { + args, + function: process.env.ORACLE_CONTRACT_ENTRY_FUNCTION as string, + gasBudget: DEFAULT_GAS_BUDGET, + gasObjectId, + module, + packageObjectId, + sender: oracleAddress, + }; + const result = await connection.callMoveFunction(request); + const created = result.objectEffectsSummary.created_objects; + if (created.length !== 1) { + throw new Error(`Unexpected number of objects created: ${created}`); + } + console.info('Created object', created); + return created[0].id; + } + + async getGasAndOracle( + connection: Connection, + oracleAddress: string + ): Promise<[string, string]> { + const objects = await connection.bulkFetchObjects(oracleAddress); + const gasCoin = objects.filter( + (o) => o.objType === '0x2::Coin::Coin<0x2::GAS::GAS>' + )[0].id; + const oracle_object_identifier = + this.getPackageAndModule().join('::') + + '::' + + (process.env.ORACLE_CONTRACT_ADMIN_IDENTIFIER as string); + const oracle = objects.filter( + (o) => o.objType === oracle_object_identifier + ); + if (oracle.length !== 1) { + throw new Error(`Unexpected number of oracle object: ${oracle}`); + } + + return [ + this.formatObjectId(gasCoin), + this.formatObjectId(oracle[0].id), + ]; + } + + formatObjectId(id: string): string { + return `0x${id}`; + } + + getPackageAndModule(): [string, string] { + const package_name = process.env.ORACLE_CONTRACT_PACKAGE || '0x2'; + const module_name = + process.env.ORACLE_CONTRACT_MODULE || 'CrossChainAirdrop'; + return [package_name, module_name]; + } } /** diff --git a/nft_mirror/oracle_server/src/sdk/gateway-generated-schema.ts b/nft_mirror/oracle_server/src/sdk/gateway-generated-schema.ts index a6edbf4b7668b..e7b74bfadfee0 100644 --- a/nft_mirror/oracle_server/src/sdk/gateway-generated-schema.ts +++ b/nft_mirror/oracle_server/src/sdk/gateway-generated-schema.ts @@ -30,10 +30,18 @@ export interface paths { */ post: operations['call']; }; + '/docs': { + /** Generate OpenAPI documentation. */ + get: operations['docs']; + }; '/object_info': { /** Returns the object information for a specified object. */ get: operations['object_info']; }; + '/object_schema': { + /** Returns the schema for a specified object. */ + get: operations['object_schema']; + }; '/objects': { /** Returns list of objects owned by an address. */ get: operations['get_objects']; @@ -101,7 +109,7 @@ export interface components { /** @description Request containing the information required to execute a move module. */ CallRequest: { /** @description Required; JSON representation of the arguments */ - args: unknown[]; + args: components['schemas']['SuiJsonValue'][]; /** @description Required; Name of the function to be called in the move module */ function: string; /** @@ -117,6 +125,13 @@ export interface components { packageObjectId: string; /** @description Required; Hex code as string representing the sender's address */ sender: string; + /** @description Optional; The argument types to be parsed */ + typeArgs?: string[] | null; + }; + /** @description Response containing the API documentation. */ + DocumentationResponse: { + /** @description A JSON object containing the OpenAPI definition for this API. */ + documentation: unknown; }; /** @description Response containing the resulting wallet & network config of the provided genesis configuration. */ GenesisResponse: { @@ -158,6 +173,12 @@ export interface components { /** @description Sequence number of the object */ version: string; }; + /** @description Response containing the information of an object schema if found, otherwise an error is returned. */ + ObjectSchemaResponse: { + /** @description JSON representation of the object schema */ + schema: unknown; + }; + SuiJsonValue: unknown; /** @description Request containing the address that requires a sync. */ SyncRequest: { /** @description Required; Hex code as string representing the address */ @@ -235,6 +256,17 @@ export interface operations { }; }; }; + /** Generate OpenAPI documentation. */ + docs: { + responses: { + /** successful operation */ + 200: { + content: { + 'application/json': components['schemas']['DocumentationResponse']; + }; + }; + }; + }; /** Returns the object information for a specified object. */ object_info: { parameters: { @@ -251,6 +283,22 @@ export interface operations { }; }; }; + /** Returns the schema for a specified object. */ + object_schema: { + parameters: { + query: { + objectId: string; + }; + }; + responses: { + /** successful operation */ + 200: { + content: { + 'application/json': components['schemas']['ObjectSchemaResponse']; + }; + }; + }; + }; /** Returns list of objects owned by an address. */ get_objects: { parameters: { diff --git a/nft_mirror/oracle_server/src/sdk/gateway.ts b/nft_mirror/oracle_server/src/sdk/gateway.ts index cd255d74c9122..9ed57ad3b436b 100644 --- a/nft_mirror/oracle_server/src/sdk/gateway.ts +++ b/nft_mirror/oracle_server/src/sdk/gateway.ts @@ -1,7 +1,37 @@ // Copyright (c) 2022, Mysten Labs, Inc. // SPDX-License-Identifier: Apache-2.0 -import { gatewayServiceAPI } from './gatewayServiceAPI'; +import { + gatewayServiceAPI, + CallRequest, + ObjectInfoResponse, +} from './gatewayServiceAPI'; + +export interface TransactionResponse { + gasUsed: number; + objectEffectsSummary: ObjectEffectsSummary; +} + +export interface ObjectEffectsSummary { + created_objects: ObjectEffect[]; + mutated_objects: ObjectEffect[]; + unwrapped_objects: ObjectEffect[]; + deleted_objects: ObjectEffect[]; + wrapped_objects: ObjectEffect[]; + events: EventEffect[]; +} + +export interface ObjectEffect { + type: string; + id: string; + version: string; + object_digest: string; +} + +export interface EventEffect { + type: string; + contents: string; +} /** * A connection to a Sui Gateway endpoint @@ -29,4 +59,85 @@ export class Connection { } = await this._gatewayAPI.getAddresses({}); return addresses; } + + /** + * Execute a Move call transaction by calling the specified function in the + * module of the given package + */ + public async callMoveFunction( + request: CallRequest + ): Promise { + const { + data: { gasUsed, objectEffectsSummary }, + } = await this._gatewayAPI.callMoveFunction(request); + return { + gasUsed, + objectEffectsSummary: objectEffectsSummary as ObjectEffectsSummary, + }; + } + + /** + * Returns list of object ids owned by an address. + */ + public async getObjectIds(address: string): Promise { + const { + data: { objects }, + } = await this._gatewayAPI.getObjects({ address }); + return objects.map(({ objectId }) => objectId); + } + + /** + * Returns the object information for a specified object. + */ + public async getObjectInfo( + objectId: string + ): Promise { + try { + const { data } = await this._gatewayAPI.getObjectInfo({ objectId }); + return data; + } catch (error) { + console.error('Encounter error for ', objectId, error); + } + return null; + } + + /** + * Returns all objects owned by an address of a given type(optional) + */ + public async bulkFetchObjects( + address: string, + objectType?: string + ): Promise { + // TODO: support get objects by types in Gateway + const objectIds = await this.getObjectIds(address); + // TODO Gateway needs to support + // concurrent requests before we can use the following code + // const objects = await Promise.all( + // objectIds.map(async (id) => await this.getObjectInfo(id)) + // ); + const objects = []; + for (const id of objectIds) { + const info = await this.getObjectInfo(id); + if (info != null) { + objects.push(info); + } + } + return objects.filter( + (object) => objectType == null || object.objType === objectType + ); + } + + /** + * Returns all object ids owned by an address of a given type(optional) + */ + public async bulkFetchObjectIds( + address: string, + objectType?: string + ): Promise { + // TODO: support get objects by types in Gateway + const objects = await this.bulkFetchObjects(address, objectType); + return objects.map((object) => object.id); + } } + +export { CallRequest, ObjectInfoResponse }; diff --git a/nft_mirror/oracle_server/src/sdk/gatewayServiceAPI.ts b/nft_mirror/oracle_server/src/sdk/gatewayServiceAPI.ts index 9448f0497d364..ea02bf8d99540 100644 --- a/nft_mirror/oracle_server/src/sdk/gatewayServiceAPI.ts +++ b/nft_mirror/oracle_server/src/sdk/gatewayServiceAPI.ts @@ -1,4 +1,7 @@ -import { paths as GatewayServicePaths } from './gateway-generated-schema'; +import { + paths as GatewayServicePaths, + components, +} from './gateway-generated-schema'; import { Fetcher } from 'openapi-typescript-fetch'; export interface GatewayConnection { @@ -18,5 +21,11 @@ export const gatewayServiceAPI = ({ baseUrl }: GatewayConnection) => { return { getAddresses: fetcher.path('/addresses').method('get').create(), + getObjects: fetcher.path('/objects').method('get').create(), + getObjectInfo: fetcher.path('/object_info').method('get').create(), + callMoveFunction: fetcher.path('/call').method('post').create(), }; }; + +export type CallRequest = components['schemas']['CallRequest']; +export type ObjectInfoResponse = components['schemas']['ObjectInfoResponse'];