diff --git a/src/tokens/abimapper.service.ts b/src/tokens/abimapper.service.ts index 0147ceb..3317fe0 100644 --- a/src/tokens/abimapper.service.ts +++ b/src/tokens/abimapper.service.ts @@ -132,7 +132,7 @@ export class AbiMapperService { return true; } - private getAllMethods(abi: IAbiMethod[], signatures: MethodSignature[]) { + getAllMethods(abi: IAbiMethod[], signatures: MethodSignature[]) { const methods: IAbiMethod[] = []; for (const signature of signatures) { for (const method of abi) { diff --git a/src/tokens/tokens.controller.ts b/src/tokens/tokens.controller.ts index a56a278..5b9da16 100644 --- a/src/tokens/tokens.controller.ts +++ b/src/tokens/tokens.controller.ts @@ -17,11 +17,12 @@ import { Body, Controller, Get, HttpCode, HttpStatus, Param, Post, Res } from '@nestjs/common'; import { ApiBody, ApiOperation, ApiResponse } from '@nestjs/swagger'; import { Response } from 'express'; -import { RequestContext } from '../request-context/request-context.decorator'; +import { Context, RequestContext } from '../request-context/request-context.decorator'; import { EventStreamReply } from '../event-stream/event-stream.interfaces'; import { BlockchainConnectorService } from './blockchain.service'; import { AsyncResponse, + CheckInterfaceRequest, TokenApproval, TokenBurn, TokenMint, @@ -39,7 +40,7 @@ export class TokensController { @Post('init') @HttpCode(204) @ApiOperation({ summary: 'Perform one-time initialization (if not auto-initialized)' }) - init(@RequestContext() ctx) { + init(@RequestContext() ctx: Context) { return this.service.init(ctx); } @@ -53,7 +54,7 @@ export class TokensController { @ApiResponse({ status: 200, type: TokenPoolEvent }) @ApiResponse({ status: 202, type: AsyncResponse }) async createPool( - @RequestContext() ctx, + @RequestContext() ctx: Context, @Body() dto: TokenPool, @Res({ passthrough: true }) res: Response, ) { @@ -72,10 +73,20 @@ export class TokensController { summary: 'Activate a token pool to begin receiving transfer events', }) @ApiBody({ type: TokenPoolActivate }) - activatePool(@RequestContext() ctx, @Body() dto: TokenPoolActivate) { + activatePool(@RequestContext() ctx: Context, @Body() dto: TokenPoolActivate) { return this.service.activatePool(ctx, dto); } + @Post('checkinterface') + @HttpCode(200) + @ApiOperation({ + summary: 'Check which interface methods are supported by this connector', + }) + @ApiBody({ type: CheckInterfaceRequest }) + checkInterface(@Body() dto: CheckInterfaceRequest) { + return this.service.checkInterface(dto); + } + @Post('mint') @HttpCode(202) @ApiOperation({ @@ -85,7 +96,7 @@ export class TokensController { }) @ApiBody({ type: TokenMint }) @ApiResponse({ status: 202, type: AsyncResponse }) - mint(@RequestContext() ctx, @Body() dto: TokenMint) { + mint(@RequestContext() ctx: Context, @Body() dto: TokenMint) { return this.service.mint(ctx, dto); } @@ -98,7 +109,7 @@ export class TokensController { }) @ApiBody({ type: TokenTransfer }) @ApiResponse({ status: 202, type: AsyncResponse }) - transfer(@RequestContext() ctx, @Body() dto: TokenTransfer) { + transfer(@RequestContext() ctx: Context, @Body() dto: TokenTransfer) { return this.service.transfer(ctx, dto); } @@ -110,7 +121,7 @@ export class TokensController { }) @ApiBody({ type: TokenApproval }) @ApiResponse({ status: 202, type: AsyncResponse }) - approve(@RequestContext() ctx, @Body() dto: TokenApproval) { + approve(@RequestContext() ctx: Context, @Body() dto: TokenApproval) { return this.service.approval(ctx, dto); } @@ -123,14 +134,14 @@ export class TokensController { }) @ApiBody({ type: TokenBurn }) @ApiResponse({ status: 202, type: AsyncResponse }) - burn(@RequestContext() ctx, @Body() dto: TokenBurn) { + burn(@RequestContext() ctx: Context, @Body() dto: TokenBurn) { return this.service.burn(ctx, dto); } @Get('receipt/:id') @ApiOperation({ summary: 'Retrieve the result of an async operation' }) @ApiResponse({ status: 200, type: EventStreamReply }) - getReceipt(@RequestContext() ctx, @Param('id') id: string) { + getReceipt(@RequestContext() ctx: Context, @Param('id') id: string) { return this.blockchain.getReceipt(ctx, id); } } diff --git a/src/tokens/tokens.interfaces.ts b/src/tokens/tokens.interfaces.ts index 07b01c1..39ea7c5 100644 --- a/src/tokens/tokens.interfaces.ts +++ b/src/tokens/tokens.interfaces.ts @@ -15,7 +15,7 @@ // limitations under the License. import { ApiProperty, OmitType } from '@nestjs/swagger'; -import { IsEnum, IsNotEmpty, IsOptional } from 'class-validator'; +import { Equals, IsDefined, IsEnum, IsNotEmpty, IsOptional } from 'class-validator'; import { Event } from '../event-stream/event-stream.interfaces'; // Ethconnect interfaces @@ -95,6 +95,11 @@ export enum TokenType { NONFUNGIBLE = 'nonfungible', } +export enum InterfaceFormat { + ABI = 'abi', + FFI = 'ffi', +} + export interface IPoolLocator { address: string | null; schema: string | null; @@ -221,9 +226,37 @@ export class TokenPoolActivate { } export class TokenInterface { + @ApiProperty({ enum: InterfaceFormat }) + @Equals(InterfaceFormat.ABI) + format: InterfaceFormat; + @ApiProperty({ isArray: true }) - @IsOptional() - abi?: IAbiMethod[]; + @IsDefined() + methods: IAbiMethod[]; +} + +export class CheckInterfaceRequest extends TokenInterface { + @ApiProperty() + @IsNotEmpty() + poolLocator: string; +} + +type TokenAbi = { + [op in TokenOperation]: TokenInterface; +}; + +export class CheckInterfaceResponse implements TokenAbi { + @ApiProperty() + approval: TokenInterface; + + @ApiProperty() + burn: TokenInterface; + + @ApiProperty() + mint: TokenInterface; + + @ApiProperty() + transfer: TokenInterface; } export class TokenTransfer { @@ -353,6 +386,9 @@ export class TokenPoolEvent extends tokenEventBase { @ApiProperty() standard: string; + @ApiProperty() + interfaceFormat: InterfaceFormat; + @ApiProperty() symbol: string; diff --git a/src/tokens/tokens.listener.ts b/src/tokens/tokens.listener.ts index 2af2a8f..055986a 100644 --- a/src/tokens/tokens.listener.ts +++ b/src/tokens/tokens.listener.ts @@ -32,6 +32,7 @@ import { TokenTransferEvent, TokenType, TransferEvent, + InterfaceFormat, } from './tokens.interfaces'; import { decodeHex, @@ -150,6 +151,7 @@ export class TokenListener implements EventListener { event: 'token-pool', data: { standard: type === TokenType.FUNGIBLE ? 'ERC20' : 'ERC721', + interfaceFormat: InterfaceFormat.ABI, poolLocator: packPoolLocator(poolLocator), type, signer: event.inputSigner, diff --git a/src/tokens/tokens.service.spec.ts b/src/tokens/tokens.service.spec.ts index 7463799..790e06a 100644 --- a/src/tokens/tokens.service.spec.ts +++ b/src/tokens/tokens.service.spec.ts @@ -39,6 +39,7 @@ import { EthConnectMsgRequest, EthConnectReturn, IAbiMethod, + InterfaceFormat, TokenBurn, TokenMint, TokenPool, @@ -267,6 +268,7 @@ describe('TokensService', () => { data: `{"tx":${TX}}`, poolLocator: ERC20_NO_DATA_POOL_ID, standard: 'ERC20', + interfaceFormat: InterfaceFormat.ABI, type: 'fungible', symbol: SYMBOL, decimals: 18, @@ -295,6 +297,7 @@ describe('TokensService', () => { const response: TokenPoolEvent = { poolLocator: ERC20_NO_DATA_POOL_ID, standard: 'ERC20', + interfaceFormat: InterfaceFormat.ABI, type: TokenType.FUNGIBLE, symbol: SYMBOL, decimals: 18, @@ -458,6 +461,7 @@ describe('TokensService', () => { data: `{"tx":${TX}}`, poolLocator: ERC20_WITH_DATA_POOL_ID, standard: 'ERC20', + interfaceFormat: InterfaceFormat.ABI, type: 'fungible', symbol: SYMBOL, decimals: 18, @@ -490,6 +494,7 @@ describe('TokensService', () => { data: `{"tx":${TX}}`, poolLocator: ERC20_WITH_DATA_POOL_ID, standard: 'ERC20', + interfaceFormat: InterfaceFormat.ABI, type: 'fungible', symbol: SYMBOL, decimals: 18, @@ -518,6 +523,7 @@ describe('TokensService', () => { const response: TokenPoolEvent = { poolLocator: ERC20_WITH_DATA_POOL_ID, standard: 'ERC20', + interfaceFormat: InterfaceFormat.ABI, type: TokenType.FUNGIBLE, symbol: SYMBOL, decimals: 18, @@ -680,6 +686,7 @@ describe('TokensService', () => { data: `{"tx":${TX}}`, poolLocator: ERC721_NO_DATA_POOL_ID, standard: 'ERC721', + interfaceFormat: InterfaceFormat.ABI, type: 'nonfungible', symbol: SYMBOL, decimals: 0, @@ -708,6 +715,7 @@ describe('TokensService', () => { const response: TokenPoolEvent = { poolLocator: ERC721_NO_DATA_POOL_ID, standard: 'ERC721', + interfaceFormat: InterfaceFormat.ABI, type: TokenType.NONFUNGIBLE, symbol: SYMBOL, decimals: 0, @@ -888,6 +896,7 @@ describe('TokensService', () => { data: `{"tx":${TX}}`, poolLocator: ERC721_WITH_DATA_POOL_ID, standard: 'ERC721', + interfaceFormat: InterfaceFormat.ABI, type: 'nonfungible', symbol: SYMBOL, decimals: 0, @@ -921,6 +930,7 @@ describe('TokensService', () => { data: `{"tx":${TX}}`, poolLocator: ERC721_WITH_DATA_POOL_ID, standard: 'ERC721', + interfaceFormat: InterfaceFormat.ABI, type: 'nonfungible', symbol: SYMBOL, decimals: 0, @@ -949,6 +959,7 @@ describe('TokensService', () => { const response: TokenPoolEvent = { poolLocator: ERC721_WITH_DATA_POOL_ID, standard: 'ERC721', + interfaceFormat: InterfaceFormat.ABI, type: TokenType.NONFUNGIBLE, symbol: SYMBOL, decimals: 0, diff --git a/src/tokens/tokens.service.ts b/src/tokens/tokens.service.ts index e1e00be..f22d6b9 100644 --- a/src/tokens/tokens.service.ts +++ b/src/tokens/tokens.service.ts @@ -21,10 +21,15 @@ import { EventStreamProxyGateway } from '../eventstream-proxy/eventstream-proxy. import { Context, newContext } from '../request-context/request-context.decorator'; import { AsyncResponse, + CheckInterfaceRequest, + CheckInterfaceResponse, + IAbiMethod, + InterfaceFormat, IPoolLocator, IValidPoolLocator, TokenApproval, TokenBurn, + TokenInterface, TokenMint, TokenPool, TokenPoolActivate, @@ -48,6 +53,7 @@ import { Transfer as ERC20Transfer, Name as ERC20Name, Symbol as ERC20Symbol, + DynamicMethods as ERC20Methods, } from './erc20'; import { Approval as ERC721Approval, @@ -55,6 +61,7 @@ import { Transfer as ERC721Transfer, Name as ERC721Name, Symbol as ERC721Symbol, + DynamicMethods as ERC721Methods, } from './erc721'; @Injectable() @@ -263,6 +270,7 @@ export class TokensService { data: dto.data, poolLocator: packPoolLocator(poolLocator), standard: dto.type === TokenType.FUNGIBLE ? 'ERC20' : 'ERC721', + interfaceFormat: InterfaceFormat.ABI, type: dto.type, symbol: poolInfo.symbol, decimals: poolInfo.decimals, @@ -363,6 +371,7 @@ export class TokensService { const tokenPoolEvent: TokenPoolEvent = { poolLocator: dto.poolLocator, standard: poolLocator.type === TokenType.FUNGIBLE ? 'ERC20' : 'ERC721', + interfaceFormat: InterfaceFormat.ABI, type: poolLocator.type, symbol: poolInfo.symbol, decimals: poolInfo.decimals, @@ -376,6 +385,25 @@ export class TokensService { return tokenPoolEvent; } + checkInterface(dto: CheckInterfaceRequest): CheckInterfaceResponse { + const poolLocator = unpackPoolLocator(dto.poolLocator); + if (!validatePoolLocator(poolLocator)) { + throw new BadRequestException('Invalid pool locator'); + } + + const wrapMethods = (methods: IAbiMethod[]): TokenInterface => { + return { format: InterfaceFormat.ABI, methods }; + }; + + const methods = poolLocator.type === TokenType.FUNGIBLE ? ERC20Methods : ERC721Methods; + return { + approval: wrapMethods(this.mapper.getAllMethods(dto.methods, methods.approval)), + burn: wrapMethods(this.mapper.getAllMethods(dto.methods, methods.burn)), + mint: wrapMethods(this.mapper.getAllMethods(dto.methods, methods.mint)), + transfer: wrapMethods(this.mapper.getAllMethods(dto.methods, methods.transfer)), + }; + } + private async getAbiForMint(ctx: Context, poolLocator: IValidPoolLocator, dto: TokenMint) { const supportsUri = dto.uri !== undefined && (await this.mapper.supportsMintWithUri(ctx, poolLocator.address)); @@ -388,7 +416,7 @@ export class TokensService { throw new BadRequestException('Invalid pool locator'); } - const abi = dto.interface?.abi || (await this.getAbiForMint(ctx, poolLocator, dto)); + const abi = dto.interface?.methods || (await this.getAbiForMint(ctx, poolLocator, dto)); const { method, params } = this.mapper.getMethodAndParams( abi, poolLocator.type === TokenType.FUNGIBLE, @@ -412,7 +440,7 @@ export class TokensService { throw new BadRequestException('Invalid pool locator'); } - const abi = dto.interface?.abi || this.mapper.getAbi(poolLocator.schema); + const abi = dto.interface?.methods || this.mapper.getAbi(poolLocator.schema); const { method, params } = this.mapper.getMethodAndParams( abi, poolLocator.type === TokenType.FUNGIBLE, @@ -436,7 +464,7 @@ export class TokensService { throw new BadRequestException('Invalid pool locator'); } - const abi = dto.interface?.abi || this.mapper.getAbi(poolLocator.schema); + const abi = dto.interface?.methods || this.mapper.getAbi(poolLocator.schema); const { method, params } = this.mapper.getMethodAndParams( abi, poolLocator.type === TokenType.FUNGIBLE, @@ -460,7 +488,7 @@ export class TokensService { throw new BadRequestException('Invalid pool locator'); } - const abi = dto.interface?.abi || this.mapper.getAbi(poolLocator.schema); + const abi = dto.interface?.methods || this.mapper.getAbi(poolLocator.schema); const { method, params } = this.mapper.getMethodAndParams( abi, poolLocator.type === TokenType.FUNGIBLE, diff --git a/test/suites/erc20.ts b/test/suites/erc20.ts index 9fa2167..7e7e9d8 100644 --- a/test/suites/erc20.ts +++ b/test/suites/erc20.ts @@ -17,10 +17,13 @@ import ERC20WithDataABI from '../../src/abi/ERC20WithData.json'; import ERC20NoDataABI from '../../src/abi/ERC20NoData.json'; import { + CheckInterfaceRequest, + CheckInterfaceResponse, EthConnectAsyncResponse, EthConnectMsgRequest, EthConnectReturn, IAbiMethod, + InterfaceFormat, TokenApproval, TokenBurn, TokenMint, @@ -50,7 +53,9 @@ const ERC20_WITH_DATA_SCHEMA = 'ERC20WithData'; const ERC20_WITH_DATA_POOL_ID = `address=${CONTRACT_ADDRESS}&schema=${ERC20_WITH_DATA_SCHEMA}&type=${TokenType.FUNGIBLE}`; const MINT_NO_DATA = 'mint'; +const TRANSFER_FROM_NO_DATA = 'transferFrom'; const TRANSFER_NO_DATA = 'transfer'; +const BURN_FROM_NO_DATA = 'burnFrom'; const BURN_NO_DATA = 'burn'; const APPROVE_NO_DATA = 'approve'; const MINT_WITH_DATA = 'mintWithData'; @@ -559,7 +564,8 @@ export default (context: TestContext) => { poolLocator: ERC20_NO_DATA_POOL_ID, from: IDENTITY, interface: { - abi: burnMethods, + format: InterfaceFormat.ABI, + methods: burnMethods, }, }; @@ -627,7 +633,8 @@ export default (context: TestContext) => { poolLocator: ERC20_NO_DATA_POOL_ID, from: '0x2', interface: { - abi: burnMethods, + format: InterfaceFormat.ABI, + methods: burnMethods, }, }; @@ -653,5 +660,39 @@ export default (context: TestContext) => { expect(context.http.post).toHaveBeenCalledTimes(1); expect(context.http.post).toHaveBeenCalledWith(BASE_URL, mockEthConnectRequest, OPTIONS); }); + + it('Check interface', async () => { + const request: CheckInterfaceRequest = { + poolLocator: ERC20_NO_DATA_POOL_ID, + format: InterfaceFormat.ABI, + methods: ERC20NoDataABI.abi, + }; + + const response: CheckInterfaceResponse = { + approval: { + format: InterfaceFormat.ABI, + methods: ERC20NoDataABI.abi.filter(m => m.name === APPROVE_NO_DATA), + }, + burn: { + format: InterfaceFormat.ABI, + methods: ERC20NoDataABI.abi.filter( + m => m.name === BURN_NO_DATA || m.name === BURN_FROM_NO_DATA, + ), + }, + mint: { + format: InterfaceFormat.ABI, + methods: ERC20NoDataABI.abi.filter(m => m.name === MINT_NO_DATA), + }, + transfer: { + format: InterfaceFormat.ABI, + methods: [ + ...ERC20NoDataABI.abi.filter(m => m.name === TRANSFER_NO_DATA), + ...ERC20NoDataABI.abi.filter(m => m.name === TRANSFER_FROM_NO_DATA), + ], + }, + }; + + await context.server.post('/checkinterface').send(request).expect(200).expect(response); + }); }); }; diff --git a/test/suites/erc721.ts b/test/suites/erc721.ts index 36638ca..86eb866 100644 --- a/test/suites/erc721.ts +++ b/test/suites/erc721.ts @@ -17,10 +17,13 @@ import ERC721NoDataABI from '../../src/abi/ERC721NoData.json'; import ERC721WithDataABI from '../../src/abi/ERC721WithData.json'; import { + CheckInterfaceRequest, + CheckInterfaceResponse, EthConnectAsyncResponse, EthConnectMsgRequest, EthConnectReturn, IAbiMethod, + InterfaceFormat, TokenApproval, TokenBurn, TokenMint, @@ -540,7 +543,8 @@ export default (context: TestContext) => { poolLocator: ERC721_NO_DATA_POOL_ID, to: '0x123', interface: { - abi: [safeMintAutoIndex], + format: InterfaceFormat.ABI, + methods: [safeMintAutoIndex], }, }; @@ -566,5 +570,44 @@ export default (context: TestContext) => { expect(context.http.post).toHaveBeenCalledTimes(1); expect(context.http.post).toHaveBeenCalledWith(BASE_URL, mockEthConnectRequest, OPTIONS); }); + + it('Check interface', async () => { + const request: CheckInterfaceRequest = { + poolLocator: ERC721_NO_DATA_POOL_ID, + format: InterfaceFormat.ABI, + methods: ERC721NoDataABI.abi, + }; + + const response: CheckInterfaceResponse = { + approval: { + format: InterfaceFormat.ABI, + methods: [ + ...ERC721NoDataABI.abi.filter(m => m.name === APPROVE_NO_DATA), + ...ERC721NoDataABI.abi.filter(m => m.name === APPROVE_FOR_ALL_NO_DATA), + ], + }, + burn: { + format: InterfaceFormat.ABI, + methods: ERC721NoDataABI.abi.filter(m => m.name === BURN_NO_DATA), + }, + mint: { + format: InterfaceFormat.ABI, + methods: ERC721NoDataABI.abi.filter(m => m.name === MINT_NO_DATA), + }, + transfer: { + format: InterfaceFormat.ABI, + methods: [ + ...ERC721NoDataABI.abi.filter( + m => m.name === TRANSFER_NO_DATA && m.inputs.length === 4, + ), + ...ERC721NoDataABI.abi.filter( + m => m.name === TRANSFER_NO_DATA && m.inputs.length === 3, + ), + ], + }, + }; + + await context.server.post('/checkinterface').send(request).expect(200).expect(response); + }); }); };