diff --git a/src/__tests__/erc165/erc165.test.ts b/src/__tests__/erc165/erc165.test.ts new file mode 100644 index 0000000..945b9ea --- /dev/null +++ b/src/__tests__/erc165/erc165.test.ts @@ -0,0 +1,53 @@ +import core from '../../core'; +import erc165 from '../../erc165'; +import { testGraphql } from '../utils'; + +const { execQuery } = testGraphql({ optsOverride: { plugins: [core, erc165] } }); + +test('erc165: Cryptokitties supports ERC165 interface', async () => { + const query = ` + { + account(address:"0x06012c8cf97BEaD5deAe237070F9587f8E7A266d") { + supportsInterface(interfaceID: "0x01ffc9a7") + } + } + `; + + const result = await execQuery(query); + expect(result.errors).toBeUndefined(); + expect(result.data).not.toBeUndefined(); + expect(result.data.account.supportsInterface).toEqual(true); +}); + +test('erc165: Cryptokitties supports ERC721 interface', async () => { + const query = ` + { + account(address:"0x06012c8cf97BEaD5deAe237070F9587f8E7A266d") { + supportsInterface(interfaceID: "0x9a20483d") + } + } + `; + + const result = await execQuery(query); + expect(result.errors).toBeUndefined(); + expect(result.data).not.toBeUndefined(); + + expect(result.data.account.supportsInterface).toEqual(true); +}); + +test('erc165: OmiseGO does not support ERC165', async () => { + const query = ` + { + account(address:"0xd26114cd6EE289AccF82350c8d8487fedB8A0C07") { + address, + supportsInterface(interfaceID: "0x01ffc9a7") + } + } + `; + + const result = await execQuery(query); + expect(result.errors).toBeUndefined(); + expect(result.data).not.toBeUndefined(); + + expect(result.data.account.supportsInterface).toEqual(false); +}); diff --git a/src/__tests__/erc721/erc721.test.ts b/src/__tests__/erc721/erc721.test.ts new file mode 100644 index 0000000..faab365 --- /dev/null +++ b/src/__tests__/erc721/erc721.test.ts @@ -0,0 +1,209 @@ +import core from '../../core'; +import erc165 from '../../erc165'; +import erc20 from '../../erc20'; +import erc721 from '../../erc721'; +import { testGraphql } from '../utils'; + +const { execQuery } = testGraphql({ optsOverride: { plugins: [core, erc165, erc721] } }); + +test('erc721: nftToken balanceOf query #1', async () => { + const query = ` + { + account(address: "0x6EbeAf8e8E946F0716E6533A6f2cefc83f60e8Ab") { + nftToken { + balanceOf(owner: "0xD418c5d0c4a3D87a6c555B7aA41f13EF87485Ec6") + } + } + } + `; + const result = await execQuery(query); + expect(result.errors).toBeUndefined(); + expect(result.data).not.toBeUndefined(); + + expect(result.data).toEqual({ + account: { + nftToken: { + balanceOf: 0, + }, + }, + }); +}); + +test('erc721: nftToken balanceOf query #2', async () => { + const query = ` + { + account(address: "0x6EbeAf8e8E946F0716E6533A6f2cefc83f60e8Ab") { + nftToken { + balanceOf(owner: "0x6f00cE7253bFD3A5A1c307b5E13814BF3433C72f") + } + } + } + `; + const result = await execQuery(query); + expect(result.errors).toBeUndefined(); + expect(result.data).not.toBeUndefined(); + + expect(result.data).toEqual({ + account: { + nftToken: { + balanceOf: 5, + }, + }, + }); +}); + +test('erc721: nftToken ownerOf query', async () => { + const query = ` + { + account(address: "0x6EbeAf8e8E946F0716E6533A6f2cefc83f60e8Ab") { + nftToken { + ownerOf(tokenId: 143880) + } + } + } + `; + const result = await execQuery(query); + expect(result.errors).toBeUndefined(); + expect(result.data).not.toBeUndefined(); + + expect(result.data).toEqual({ + account: { + nftToken: { + ownerOf: '0x6f00cE7253bFD3A5A1c307b5E13814BF3433C72f', + }, + }, + }); +}); + +test('erc721: nftToken getApproved query', async () => { + const query = ` + { + account(address:"0x6EbeAf8e8E946F0716E6533A6f2cefc83f60e8Ab") { + nftToken { + getApproved(tokenId: 33525) + } + } + } + `; + const result = await execQuery(query); + expect(result.errors).toBeUndefined(); + expect(result.data).not.toBeUndefined(); + + expect(result.data).toEqual({ + account: { + nftToken: { + getApproved: '0x0000000000000000000000000000000000000000', + }, + }, + }); +}); + +test('erc721: nftToken isApprovedForAll query', async () => { + const query = ` + { + account(address:"0x6EbeAf8e8E946F0716E6533A6f2cefc83f60e8Ab") { + nftToken { + isApprovedForAll(owner: "0xb85e9bdfCA73a536BE641bB5eacBA0772eA3E61E", operator: "0xD418c5d0c4a3D87a6c555B7aA41f13EF87485Ec6") + } + } + } + `; + const result = await execQuery(query); + expect(result.errors).toBeUndefined(); + expect(result.data).not.toBeUndefined(); + + expect(result.data).toEqual({ + account: { + nftToken: { + isApprovedForAll: false, + }, + }, + }); +}); + +//44 + +test('erc721: not decodable', async () => { + const query = ` + { + block(number: 5000000) { + hash + transactionAt(index: 66) { + value + decoded { + standard + operation + ... on ERC721TransferFrom { + from { + account { + address + } + } + to { + account { + address + } + } + tokenId + } + } + } + } + } + `; + + const result = await execQuery(query); + expect(result.errors).toBeUndefined(); + expect(result.data).not.toBeUndefined(); + + const tx = result.data.block.transactionAt; + expect(tx.value).toBeGreaterThan(0); + expect(tx.decoded).toEqual(null); +}); + +test('erc721: decode transfer log', async () => { + const query = ` + { + transaction(hash: "0x5e9e7570cde63860a7cb71542729deb6d4dd796af70bb464050ca06ec7fc4bc9") { + hash + logs{ + decoded { + ... on ERC721TransferEvent{ + from { + account { + address + } + } + to { + account { + address + } + }, + tokenId + } + } + } + } + }`; + + const result = await execQuery(query); + expect(result.errors).toBeUndefined(); + expect(result.data).not.toBeUndefined(); + + const tranferLog = result.data.transaction.logs[0]; + expect(tranferLog).toEqual({ + decoded: { + from: { + account: { + address: '0x0000000000000000000000000000000000000000', + }, + }, + to: { + account: { + address: '0xc93227eee6e77db998a1ff5b01049fec8a5694cc', + }, + }, + tokenId: 43820, + }, + }); +}); diff --git a/src/abi/README.md b/src/abi/README.md index 670cb5a..33c96b8 100644 --- a/src/abi/README.md +++ b/src/abi/README.md @@ -10,10 +10,12 @@ We classify supported ABIs in two types: ## Supported ABIs -| Standard | Type | Entity | Specification | Comments | -| -------- | --------- | ------ | ------------- | --------------------------------------------------------------- | -| ERC20 | Entity |  Token | [link][1] | | -| ERC223 | Extension |  Token | [link][2] | Private ERC20 implementation change. No action needed in ethql. | +| Standard | Type | Entity | Specification | Comments | +| -------- | --------- | --------- | ------------- | --------------------------------------------------------------- | +| ERC20 | Entity |  Token | [link][1] | | +| ERC223 | Extension |  Token | [link][2] | Private ERC20 implementation change. No action needed in ethql. | +| ERC165 | Entity | Interface | [link][3] | | +| ERC721 | Entity | Token | [link][4] | | ## ethql naming scheme @@ -28,3 +30,5 @@ logs pertaining to several standards that relate to the same entity. [1]: https://github.com/ethereum/EIPs/blob/master/EIPS/eip-20.md [2]: https://github.com/ethereum/EIPs/issues/223 +[3]: https://github.com/ethereum/EIPs/blob/master/EIPS/eip-165.md +[4]: https://github.com/ethereum/EIPs/blob/master/EIPS/eip-721.md diff --git a/src/abi/erc165.json b/src/abi/erc165.json new file mode 100644 index 0000000..6a40662 --- /dev/null +++ b/src/abi/erc165.json @@ -0,0 +1,21 @@ +[ + { + "constant": true, + "inputs": [ + { + "name": "interfaceID", + "type": "bytes4" + } + ], + "name": "supportsInterface", + "outputs": [ + { + "name": "", + "type": "bool" + } + ], + "payable": false, + "stateMutability": "view", + "type": "function" + } +] diff --git a/src/abi/erc721.json b/src/abi/erc721.json new file mode 100644 index 0000000..5270e10 --- /dev/null +++ b/src/abi/erc721.json @@ -0,0 +1,395 @@ +[ + { + "constant": true, + "inputs": [ + { + "name": "_interfaceId", + "type": "bytes4" + } + ], + "name": "supportsInterface", + "outputs": [ + { + "name": "", + "type": "bool" + } + ], + "payable": false, + "stateMutability": "view", + "type": "function" + }, + { + "constant": true, + "inputs": [], + "name": "name", + "outputs": [ + { + "name": "_name", + "type": "string" + } + ], + "payable": false, + "stateMutability": "view", + "type": "function" + }, + { + "constant": true, + "inputs": [ + { + "name": "_tokenId", + "type": "uint256" + } + ], + "name": "getApproved", + "outputs": [ + { + "name": "_operator", + "type": "address" + } + ], + "payable": false, + "stateMutability": "view", + "type": "function" + }, + { + "constant": false, + "inputs": [ + { + "name": "_to", + "type": "address" + }, + { + "name": "_tokenId", + "type": "uint256" + } + ], + "name": "approve", + "outputs": [], + "payable": false, + "stateMutability": "nonpayable", + "type": "function" + }, + { + "constant": true, + "inputs": [], + "name": "totalSupply", + "outputs": [ + { + "name": "", + "type": "uint256" + } + ], + "payable": false, + "stateMutability": "view", + "type": "function" + }, + { + "constant": false, + "inputs": [ + { + "name": "_from", + "type": "address" + }, + { + "name": "_to", + "type": "address" + }, + { + "name": "_tokenId", + "type": "uint256" + } + ], + "name": "transferFrom", + "outputs": [], + "payable": false, + "stateMutability": "nonpayable", + "type": "function" + }, + { + "constant": true, + "inputs": [ + { + "name": "_owner", + "type": "address" + }, + { + "name": "_index", + "type": "uint256" + } + ], + "name": "tokenOfOwnerByIndex", + "outputs": [ + { + "name": "_tokenId", + "type": "uint256" + } + ], + "payable": false, + "stateMutability": "view", + "type": "function" + }, + { + "constant": false, + "inputs": [ + { + "name": "_from", + "type": "address" + }, + { + "name": "_to", + "type": "address" + }, + { + "name": "_tokenId", + "type": "uint256" + } + ], + "name": "safeTransferFrom", + "outputs": [], + "payable": false, + "stateMutability": "nonpayable", + "type": "function" + }, + { + "constant": true, + "inputs": [ + { + "name": "_tokenId", + "type": "uint256" + } + ], + "name": "exists", + "outputs": [ + { + "name": "_exists", + "type": "bool" + } + ], + "payable": false, + "stateMutability": "view", + "type": "function" + }, + { + "constant": true, + "inputs": [ + { + "name": "_index", + "type": "uint256" + } + ], + "name": "tokenByIndex", + "outputs": [ + { + "name": "", + "type": "uint256" + } + ], + "payable": false, + "stateMutability": "view", + "type": "function" + }, + { + "constant": true, + "inputs": [ + { + "name": "_tokenId", + "type": "uint256" + } + ], + "name": "ownerOf", + "outputs": [ + { + "name": "_owner", + "type": "address" + } + ], + "payable": false, + "stateMutability": "view", + "type": "function" + }, + { + "constant": true, + "inputs": [ + { + "name": "_owner", + "type": "address" + } + ], + "name": "balanceOf", + "outputs": [ + { + "name": "_balance", + "type": "uint256" + } + ], + "payable": false, + "stateMutability": "view", + "type": "function" + }, + { + "constant": true, + "inputs": [], + "name": "symbol", + "outputs": [ + { + "name": "_symbol", + "type": "string" + } + ], + "payable": false, + "stateMutability": "view", + "type": "function" + }, + { + "constant": false, + "inputs": [ + { + "name": "_operator", + "type": "address" + }, + { + "name": "_approved", + "type": "bool" + } + ], + "name": "setApprovalForAll", + "outputs": [], + "payable": false, + "stateMutability": "nonpayable", + "type": "function" + }, + { + "constant": false, + "inputs": [ + { + "name": "_from", + "type": "address" + }, + { + "name": "_to", + "type": "address" + }, + { + "name": "_tokenId", + "type": "uint256" + }, + { + "name": "_data", + "type": "bytes" + } + ], + "name": "safeTransferFrom", + "outputs": [], + "payable": false, + "stateMutability": "nonpayable", + "type": "function" + }, + { + "constant": true, + "inputs": [ + { + "name": "_tokenId", + "type": "uint256" + } + ], + "name": "tokenURI", + "outputs": [ + { + "name": "", + "type": "string" + } + ], + "payable": false, + "stateMutability": "view", + "type": "function" + }, + { + "constant": true, + "inputs": [ + { + "name": "_owner", + "type": "address" + }, + { + "name": "_operator", + "type": "address" + } + ], + "name": "isApprovedForAll", + "outputs": [ + { + "name": "", + "type": "bool" + } + ], + "payable": false, + "stateMutability": "view", + "type": "function" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "name": "_from", + "type": "address" + }, + { + "indexed": true, + "name": "_to", + "type": "address" + }, + { + "indexed": true, + "name": "_tokenId", + "type": "uint256" + } + ], + "name": "Transfer", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "name": "_owner", + "type": "address" + }, + { + "indexed": true, + "name": "_approved", + "type": "address" + }, + { + "indexed": true, + "name": "_tokenId", + "type": "uint256" + } + ], + "name": "Approval", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "name": "_owner", + "type": "address" + }, + { + "indexed": true, + "name": "_operator", + "type": "address" + }, + { + "indexed": false, + "name": "_approved", + "type": "bool" + } + ], + "name": "ApprovalForAll", + "type": "event" + } +] diff --git a/src/core/resolvers/scalars.ts b/src/core/resolvers/scalars.ts index 8d603ef..30841ae 100644 --- a/src/core/resolvers/scalars.ts +++ b/src/core/resolvers/scalars.ts @@ -55,6 +55,26 @@ const Bytes32 = new GraphQLScalarType({ }, }); +// tslint:disable-next-line +const Bytes4 = new GraphQLScalarType({ + name: 'Bytes4', + description: 'A 4-byte value in hex format.', + serialize: String, + parseValue: input => { + return !Web3.utils.isHexStrict(input) || Web3.utils.hexToBytes(input).length !== 4 ? undefined : input; + }, + parseLiteral: ast => { + if ( + ast.kind !== Kind.STRING || + !Web3.utils.isHexStrict(ast.value) || + Web3.utils.hexToBytes(ast.value).length !== 4 + ) { + return undefined; + } + return String(ast.value); + }, +}); + //tslint:disable-next-line const Long = new GraphQLScalarType({ name: 'Long', @@ -69,4 +89,5 @@ export default { BlockNumber, Address, Bytes32, + Bytes4, }; diff --git a/src/core/schema/index.ts b/src/core/schema/index.ts index 610bf71..b7ef9f5 100644 --- a/src/core/schema/index.ts +++ b/src/core/schema/index.ts @@ -451,4 +451,6 @@ scalar Bytes32 scalar BlockNumber scalar Address + +scalar Bytes4 `; diff --git a/src/core/services/decoder/impl/simple.ts b/src/core/services/decoder/impl/simple.ts index 3f807c5..78e4d56 100644 --- a/src/core/services/decoder/impl/simple.ts +++ b/src/core/services/decoder/impl/simple.ts @@ -23,7 +23,6 @@ class SimpleDecodingEngine implements DecodingEngine { if (!tx.inputData) { return; } - // Iterate through the registry until we find a decoder that's capable of decoding the function call. for (const decoder of this.registry) { const decoded = decoder.abiDecoder.decodeMethod(tx.inputData); @@ -57,13 +56,16 @@ class SimpleDecodingEngine implements DecodingEngine { // Transform the returned log. const dlog = logs[0]; - return { - standard: decoder.standard, - event: dlog.name, - entity: decoder.entity, - __typename: `${decoder.standard}${_.upperFirst(dlog.name)}Event`, - ...decoder.logTransformers[dlog.name](dlog, log.transaction, context), - }; + + if (decoder.logTransformers[dlog.name]) { + return { + standard: decoder.standard, + event: dlog.name, + entity: decoder.entity, + __typename: `${decoder.standard}${_.upperFirst(dlog.name)}Event`, + ...decoder.logTransformers[dlog.name](dlog, log.transaction, context), + }; + } } } } diff --git a/src/core/services/decoder/index.ts b/src/core/services/decoder/index.ts index a830e37..46e40c4 100644 --- a/src/core/services/decoder/index.ts +++ b/src/core/services/decoder/index.ts @@ -18,7 +18,7 @@ declare module '../../../services' { // Defines the entity to which the standard belongs. // As we support new standards, this union type will expand. -type Entity = 'token' | undefined; +type Entity = 'token' | 'interface' | undefined; export type AbiDecoder = { decodeMethod: Function; diff --git a/src/erc165/index.ts b/src/erc165/index.ts new file mode 100644 index 0000000..492d866 --- /dev/null +++ b/src/erc165/index.ts @@ -0,0 +1,26 @@ +import { EthqlPluginFactory } from '../plugin'; +import resolvers from './resolvers'; +import erc165Schema from './schema/erc165'; +import { Web3Erc165Service } from './services/impl/web3-erc165-service'; + +const plugin: EthqlPluginFactory = config => ({ + name: 'erc165', + priority: 10, + schema: [erc165Schema], + resolvers, + serviceDefinitions: { + erc165Service: { + implementation: { + factory: () => context => new Web3Erc165Service(context.services.web3), + }, + }, + }, + dependsOn: { + services: ['web3', 'decoder'], + }, + order: { + after: ['core'], + }, +}); + +export default plugin; diff --git a/src/erc165/resolvers/index.ts b/src/erc165/resolvers/index.ts new file mode 100644 index 0000000..e868f11 --- /dev/null +++ b/src/erc165/resolvers/index.ts @@ -0,0 +1,13 @@ +import { EthqlContext } from '../../context'; +import { EthqlAccount } from '../../core/model'; + +async function supportsInterface(account: EthqlAccount, { interfaceID }, context: EthqlContext) { + const ABI = require(__dirname + '../../../abi/erc165.json'); + return context.services.erc165Service.supportsInterface(account.address, interfaceID); +} + +export default { + Account: { + supportsInterface, + }, +}; diff --git a/src/erc165/schema/erc165.ts b/src/erc165/schema/erc165.ts new file mode 100644 index 0000000..dc4208b --- /dev/null +++ b/src/erc165/schema/erc165.ts @@ -0,0 +1,6 @@ +export default ` + +extend type Account { + supportsInterface(interfaceID: Bytes4!): Boolean +} +`; diff --git a/src/erc165/services/impl/web3-erc165-service.ts b/src/erc165/services/impl/web3-erc165-service.ts new file mode 100644 index 0000000..681d29f --- /dev/null +++ b/src/erc165/services/impl/web3-erc165-service.ts @@ -0,0 +1,17 @@ +import Web3 = require('web3'); +import { Erc165Service } from '..'; + +export class Web3Erc165Service implements Erc165Service { + private static ABI = require(__dirname + '../../../../abi/erc165.json'); + + constructor(private web3: Web3) {} + + public async supportsInterface(address, interfaceID: string): Promise { + const contract = new this.web3.eth.Contract(Web3Erc165Service.ABI, address); + + return contract.methods + .supportsInterface(interfaceID) + .call() + .catch(() => false); + } +} diff --git a/src/erc165/services/index.ts b/src/erc165/services/index.ts new file mode 100644 index 0000000..583f4a1 --- /dev/null +++ b/src/erc165/services/index.ts @@ -0,0 +1,13 @@ +declare module '../../services' { + interface EthqlServices { + erc165Service: Erc165Service; + } + + interface EthqlServiceDefinitions { + erc165Service: EthqlServiceDefinition<{}, Erc165Service>; + } +} + +export interface Erc165Service { + supportsInterface(address, interfaceId: string): Promise; +} diff --git a/src/erc20/schema/erc20.ts b/src/erc20/schema/erc20.ts index 0f7f975..336b5d7 100644 --- a/src/erc20/schema/erc20.ts +++ b/src/erc20/schema/erc20.ts @@ -1,45 +1,45 @@ export default ` interface ERC20Transaction { - tokenContract: TokenContract + tokenContract: ERC20TokenContract } type ERC20Transfer implements DecodedTransaction & ERC20Transaction { entity: Entity standard: String operation: String - from: TokenHolder - to: TokenHolder + from: ERC20TokenHolder + to: ERC20TokenHolder value: String - tokenContract: TokenContract + tokenContract: ERC20TokenContract } type ERC20TransferFrom implements DecodedTransaction & ERC20Transaction { entity: Entity standard: String operation: String - from: TokenHolder - to: TokenHolder + from: ERC20TokenHolder + to: ERC20TokenHolder value: String - spender: TokenHolder - tokenContract: TokenContract + spender: ERC20TokenHolder + tokenContract: ERC20TokenContract } type ERC20Approve implements DecodedTransaction & ERC20Transaction { entity: Entity standard: String operation: String - from: TokenHolder - spender: TokenHolder + from: ERC20TokenHolder + spender: ERC20TokenHolder value: String - tokenContract: TokenContract + tokenContract: ERC20TokenContract } type ERC20TransferEvent implements DecodedLog { entity: Entity standard: String event: String - from: TokenHolder - to: TokenHolder + from: ERC20TokenHolder + to: ERC20TokenHolder value: String } @@ -47,8 +47,8 @@ type ERC20ApprovalEvent implements DecodedLog { entity: Entity standard: String event: String - owner: TokenHolder - spender: TokenHolder + owner: ERC20TokenHolder + spender: ERC20TokenHolder value: String } `; diff --git a/src/erc20/schema/token.ts b/src/erc20/schema/token.ts index ee8f6e2..1e8425c 100644 --- a/src/erc20/schema/token.ts +++ b/src/erc20/schema/token.ts @@ -1,10 +1,10 @@ export default ` -type TokenHolder { +type ERC20TokenHolder { account: Account! tokenBalance: Long } -type TokenContract { +type ERC20TokenContract { account: Account symbol: String totalSupply: Long diff --git a/src/erc721/decoders/index.ts b/src/erc721/decoders/index.ts new file mode 100644 index 0000000..2ddf524 --- /dev/null +++ b/src/erc721/decoders/index.ts @@ -0,0 +1,117 @@ +import { EthqlContext } from '../../context'; +import { EthqlAccount, EthqlTransaction } from '../../core/model'; +import { createAbiDecoder, DecoderDefinition, extractParamValue } from '../../core/services/decoder'; +import { + ERC721ApprovalEvent, + ERC721ApprovalForAllEvent, + ERC721Approve, + Erc721SafeTransferFrom, + ERC721SetApprovalForAll, + Erc721TokenContract, + Erc721TokenHolder, + ERC721TransferEvent, + Erc721TransferFrom, +} from '../model'; + +type Erc721LogBindings = { + Approval: ERC721ApprovalEvent; + ApprovalForAll: ERC721ApprovalForAllEvent; + Transfer: ERC721TransferEvent; +}; + +type Erc721TxBindings = { + transferFrom: Erc721TransferFrom; + safeTransferFrom: Erc721SafeTransferFrom; + approve: ERC721Approve; + setApprovalForAll: ERC721SetApprovalForAll; +}; + +const transferFrom = (decoded: any, tx: EthqlTransaction, context: EthqlContext) => { + const tokenContract = new Erc721TokenContract(tx.to, context); + const to = new EthqlAccount(extractParamValue(decoded.params, '_to')); + + return { + tokenContract, + from: new Erc721TokenHolder(tx.from, tokenContract), + to: new Erc721TokenHolder(to, tokenContract), + tokenId: extractParamValue(decoded.params, '_tokenId'), + }; +}; + +const approve = (decoded: any, tx: EthqlTransaction, context: EthqlContext) => { + const tokenContract = new Erc721TokenContract(tx.to, context); + const approved = new EthqlAccount(extractParamValue(decoded.params, '_approved')); + + return { + tokenContract, + approved: new Erc721TokenHolder(approved, tokenContract), + tokenId: extractParamValue(decoded.params, '_tokenId'), + }; +}; + +const setApprovalForAll = (decoded: any, tx: EthqlTransaction, context: EthqlContext) => { + const tokenContract = new Erc721TokenContract(tx.to, context); + const operator = new EthqlAccount(extractParamValue(decoded.params, '_operator')); + + return { + tokenContract, + operator: new Erc721TokenHolder(operator, tokenContract), + approved: extractParamValue(decoded.params, '_approved'), + }; +}; + +/** + * ERC721 token transaction decoder. + */ +class Erc721TokenDecoder implements DecoderDefinition { + public readonly entity = 'token'; + public readonly standard = 'ERC721'; + public readonly abiDecoder = createAbiDecoder(__dirname + '../../../abi/erc721.json'); + + public readonly txTransformers = { + transferFrom, + safeTransferFrom: transferFrom, + approve, + setApprovalForAll, + }; + + public readonly logTransformers = { + Approval: (decoded: any, tx: EthqlTransaction, context: EthqlContext): ERC721ApprovalEvent => { + const tokenContract = new Erc721TokenContract(tx.to, context); + const owner = new EthqlAccount(extractParamValue(decoded.events, '_owner')); + const approved = new EthqlAccount(extractParamValue(decoded.events, '_approved')); + + return { + owner: new Erc721TokenHolder(owner, tokenContract), + approved: new Erc721TokenHolder(approved, tokenContract), + tokenId: extractParamValue(decoded.events, '_tokenId'), + }; + }, + + ApprovalForAll: (decoded: any, tx: EthqlTransaction, context: EthqlContext): ERC721ApprovalForAllEvent => { + const tokenContract = new Erc721TokenContract(tx.to, context); + const owner = new EthqlAccount(extractParamValue(decoded.events, '_owner')); + const operator = new EthqlAccount(extractParamValue(decoded.events, '_operator')); + + return { + owner: new Erc721TokenHolder(owner, tokenContract), + operator: new Erc721TokenHolder(operator, tokenContract), + approved: extractParamValue(decoded.events, '_approved'), + }; + }, + + Transfer: (decoded: any, tx: EthqlTransaction, context: EthqlContext): ERC721TransferEvent => { + const tokenContract = new Erc721TokenContract(tx.to, context); + const from = new EthqlAccount(extractParamValue(decoded.events, '_from')); + const to = new EthqlAccount(extractParamValue(decoded.events, '_to')); + + return { + from: new Erc721TokenHolder(from, tokenContract), + to: new Erc721TokenHolder(to, tokenContract), + tokenId: extractParamValue(decoded.events, '_tokenId'), + }; + }, + }; +} + +export { Erc721TokenDecoder }; diff --git a/src/erc721/index.ts b/src/erc721/index.ts new file mode 100644 index 0000000..2621ba4 --- /dev/null +++ b/src/erc721/index.ts @@ -0,0 +1,27 @@ +import { EthqlPluginFactory } from '../plugin'; +import { Erc721TokenDecoder } from './decoders'; +import resolvers from './resolvers'; +import erc721Schema from './schema/erc721'; +import erc721TokenSchema from './schema/token'; + +const plugin: EthqlPluginFactory = config => ({ + name: 'erc721', + priority: 12, + resolvers, + schema: [erc721TokenSchema, erc721Schema], + serviceDefinitions: { + decoder: { + config: { + decoders: [new Erc721TokenDecoder()], + }, + }, + }, + dependsOn: { + services: ['web3', 'ethService', 'decoder', 'erc165Service'], + }, + order: { + after: ['core'], + }, +}); + +export default plugin; diff --git a/src/erc721/model/index.ts b/src/erc721/model/index.ts new file mode 100644 index 0000000..3d0231b --- /dev/null +++ b/src/erc721/model/index.ts @@ -0,0 +1,92 @@ +import Contract from 'web3/eth/contract'; +import { EthqlContext } from '../../context'; +import { EthqlAccount } from '../../core/model'; + +export interface Erc721Transaction { + tokenContract: Erc721TokenContract; +} + +export interface Erc721SafeTransferFrom extends Erc721Transaction { + from: Erc721TokenHolder; + to: Erc721TokenHolder; + tokenId: Long; +} + +export interface Erc721TransferFrom extends Erc721Transaction { + from: Erc721TokenHolder; + to: Erc721TokenHolder; + tokenId: Long; +} + +export interface ERC721Approve extends Erc721Transaction { + approved: Erc721TokenHolder; + tokenId: Long; +} + +export interface ERC721SetApprovalForAll extends Erc721Transaction { + operator: Erc721TokenHolder; + approved: Boolean; +} + +export type ERC721TransferEvent = { + from: Erc721TokenHolder; + to: Erc721TokenHolder; + tokenId: Long; +}; + +export type ERC721ApprovalEvent = { + owner: Erc721TokenHolder; + approved: Erc721TokenHolder; + tokenId: Long; +}; + +export type ERC721ApprovalForAllEvent = { + owner: Erc721TokenHolder; + operator: Erc721TokenHolder; + approved: Boolean; +}; + +export class Erc721TokenContract { + private static ABI = require(__dirname + '../../../abi/erc721.json'); + private _contract: Contract; + + constructor(public readonly account: EthqlAccount, readonly context: EthqlContext) { + this._contract = new context.services.web3.eth.Contract(Erc721TokenContract.ABI, account.address); + } + + public async balanceOf({ owner }: { owner: string }) { + return this._contract.methods + .balanceOf(owner) + .call() + .catch(() => undefined); + } + + public async ownerOf({ tokenId }: { tokenId: Long }) { + return this._contract.methods + .ownerOf(tokenId) + .call() + .catch(() => undefined); + } + + public async getApproved({ tokenId }: { tokenId: Long }) { + return this._contract.methods + .getApproved(tokenId) + .call() + .catch(() => undefined); + } + + public async isApprovedForAll({ owner, operator }: { owner: String; operator: String }) { + return this._contract.methods + .isApprovedForAll(owner, operator) + .call() + .catch(() => undefined); + } +} + +export class Erc721TokenHolder { + constructor(public readonly account: EthqlAccount, private readonly contract: Erc721TokenContract) {} + + public async tokenBalance() { + return this.contract.balanceOf({ owner: this.account.address }); + } +} diff --git a/src/erc721/resolvers/index.ts b/src/erc721/resolvers/index.ts new file mode 100644 index 0000000..328a69c --- /dev/null +++ b/src/erc721/resolvers/index.ts @@ -0,0 +1,24 @@ +import { EthqlContext } from '../../context'; +import { EthqlAccount, EthqlAccountType, StorageAccessor } from '../../core/model'; +import { Erc721TokenContract } from '../model'; + +const interfaceId = { + erc721: '0x80ac58cd', + cryptoKitities: '0x9a20483d', +}; + +async function nftToken(account: EthqlAccount, args, context: EthqlContext) { + const { address } = account; + if ( + (await context.services.erc165Service.supportsInterface(address, interfaceId.cryptoKitities)) || + (await context.services.erc165Service.supportsInterface(address, interfaceId.erc721)) + ) { + return new Erc721TokenContract(new EthqlAccount(address), context); + } +} + +export default { + Account: { + nftToken, + }, +}; diff --git a/src/erc721/schema/erc721.ts b/src/erc721/schema/erc721.ts new file mode 100644 index 0000000..3ba280a --- /dev/null +++ b/src/erc721/schema/erc721.ts @@ -0,0 +1,70 @@ +export default ` +interface ERC721Transaction { + tokenContract: ERC721TokenContract +} + +type ERC721SafeTransferFrom implements DecodedTransaction & ERC721Transaction { + entity: Entity + standard: String + operation: String + from: ERC721TokenHolder + to: ERC721TokenHolder + tokenId: Long + tokenContract: ERC721TokenContract +} + +type ERC721TransferFrom implements DecodedTransaction & ERC721Transaction { + entity: Entity + standard: String + operation: String + from: ERC721TokenHolder + to: ERC721TokenHolder + tokenId: Long + tokenContract: ERC721TokenContract +} + +type ERC721Approve implements DecodedTransaction & ERC721Transaction { + entity: Entity + standard: String + operation: String + approved: ERC721TokenHolder + tokenId: Long + tokenContract: ERC721TokenContract +} + +type ERC721SetApprovalForAll implements DecodedTransaction & ERC721Transaction { + entity: Entity + standard: String + operation: String + operator: ERC721TokenHolder + approved: Boolean + tokenContract: ERC721TokenContract +} + +type ERC721TransferEvent implements DecodedLog { + entity: Entity + standard: String + event: String + from: ERC721TokenHolder + to: ERC721TokenHolder + tokenId: Long +} + +type ERC721ApprovalEvent implements DecodedLog { + entity: Entity + standard: String + event: String + owner: ERC721TokenHolder + approved: ERC721TokenHolder + tokenId: Long +} + +type ERC721ApprovalForAllEvent implements DecodedLog { + entity: Entity + standard: String + event: String + owner: ERC721TokenHolder + operator: ERC721TokenHolder + approved: Boolean +} +`; diff --git a/src/erc721/schema/token.ts b/src/erc721/schema/token.ts new file mode 100644 index 0000000..9217a2f --- /dev/null +++ b/src/erc721/schema/token.ts @@ -0,0 +1,20 @@ +export default ` + +extend type Account { + "Selects an account." + nftToken: ERC721TokenContract +} + +type ERC721TokenHolder { + account: Account! + tokenBalance: Long +} + +type ERC721TokenContract { + account: Account + ownerOf(tokenId: Long): String + balanceOf(owner: String): Long + getApproved(tokenId: Long): String + isApprovedForAll(owner: String, operator: String): Boolean +} +`; diff --git a/src/index.ts b/src/index.ts index 4ee721f..e08f4d4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,13 +1,16 @@ import config from './config'; import core from './core'; -import erc20 from './erc20'; +import erc165 from './erc165'; +// import erc20 from './erc20'; +import erc721 from './erc721'; + import { EthqlServer } from './server'; console.log(`Effective configuration:\n${JSON.stringify(config, null, 2)}`); const server = new EthqlServer({ config, - plugins: [core, erc20], + plugins: [core, erc165, erc721], }); process.on('SIGINT', async () => (await server.stop()) || process.exit(0));