git.delta.rocks / unique-network / refs/commits / 02c6b6f09f14

difftreelog

source

tests/src/eth/util/playgrounds/unique.dev.ts18.9 KiBsourcehistory
1// Copyright 2019-2022 Unique Network (Gibraltar) Ltd.2// SPDX-License-Identifier: Apache-2.034/* eslint-disable function-call-argument-newline */5// eslint-disable-next-line @typescript-eslint/triple-slash-reference6/// <reference path="unique.dev.d.ts" />78import {readFile} from 'fs/promises';910import Web3 from 'web3';11import {WebsocketProvider} from 'web3-core';12import {Contract} from 'web3-eth-contract';1314import solc from 'solc';1516import {evmToAddress} from '@polkadot/util-crypto';17import {IKeyringPair} from '@polkadot/types/types';1819import {ArrangeGroup, DevUniqueHelper} from '../../../util/playgrounds/unique.dev';2021import {ContractImports, CompiledContract, CrossAddress, NormalizedEvent, EthProperty} from './types';2223// Native contracts ABI24import collectionHelpersAbi from '../../abi/collectionHelpers.json' assert {type: 'json'};25import nativeFungibleAbi from '../../abi/nativeFungible.json' assert {type: 'json'};26import fungibleAbi from '../../abi/fungible.json' assert {type: 'json'};27import fungibleDeprecatedAbi from '../../abi/fungibleDeprecated.json' assert {type: 'json'};28import nonFungibleAbi from '../../abi/nonFungible.json' assert {type: 'json'};29import nonFungibleDeprecatedAbi from '../../abi/nonFungibleDeprecated.json' assert {type: 'json'};30import refungibleAbi from '../../abi/reFungible.json' assert {type: 'json'};31import refungibleDeprecatedAbi from '../../abi/reFungibleDeprecated.json' assert {type: 'json'};32import refungibleTokenAbi from '../../abi/reFungibleToken.json' assert {type: 'json'};33import refungibleTokenDeprecatedAbi from '../../abi/reFungibleTokenDeprecated.json' assert {type: 'json'};34import contractHelpersAbi from '../../abi/contractHelpers.json' assert {type: 'json'};35import {ICrossAccountId, TEthereumAccount} from '../../../util/playgrounds/types';36import {TCollectionMode} from '../../../util/playgrounds/types';3738class EthGroupBase {39  helper: EthUniqueHelper;40  gasPrice?: string;4142  constructor(helper: EthUniqueHelper) {43    this.helper = helper;44  }45  async getGasPrice() {46    if(this.gasPrice)47      return this.gasPrice;48    this.gasPrice = await this.helper.getWeb3().eth.getGasPrice();49    return this.gasPrice;50  }51}525354class ContractGroup extends EthGroupBase {55  async findImports(imports?: ContractImports[]) {56    if(!imports) return function(path: string) {57      return {error: `File not found: ${path}`};58    };5960    const knownImports = {} as { [key: string]: string };61    for(const imp of imports) {62      knownImports[imp.solPath] = (await readFile(imp.fsPath)).toString();63    }6465    return function(path: string) {66      if(path in knownImports) return {contents: knownImports[path]};67      return {error: `File not found: ${path}`};68    };69  }7071  async compile(name: string, src: string, imports?: ContractImports[]): Promise<CompiledContract> {72    const compiled = JSON.parse(solc.compile(JSON.stringify({73      language: 'Solidity',74      sources: {75        [`${name}.sol`]: {76          content: src,77        },78      },79      settings: {80        outputSelection: {81          '*': {82            '*': ['*'],83          },84        },85      },86    }), {import: await this.findImports(imports)}));8788    const hasErrors = compiled['errors']89      && compiled['errors'].length > 090      && compiled.errors.some(function(err: any) {91        return err.severity == 'error';92      });9394    if(hasErrors) {95      throw compiled.errors;96    }97    const out = compiled.contracts[`${name}.sol`][name];9899    return {100      abi: out.abi,101      object: '0x' + out.evm.bytecode.object,102    };103  }104105  async deployByCode(signer: string, name: string, src: string, imports?: ContractImports[], gas?: number, args?: any[]): Promise<Contract> {106    const compiledContract = await this.compile(name, src, imports);107    return this.deployByAbi(signer, compiledContract.abi, compiledContract.object, gas, args);108  }109110  async deployByAbi(signer: string, abi: any, object: string, gas?: number, args?: any[]): Promise<Contract> {111    const web3 = this.helper.getWeb3();112    const contract = new web3.eth.Contract(abi, undefined, {113      data: object,114      from: signer,115      gas: gas ?? this.helper.eth.DEFAULT_GAS,116    });117    return await contract.deploy({data: object, arguments: args}).send({from: signer});118  }119120}121122class NativeContractGroup extends EthGroupBase {123124  contractHelpers(caller: string) {125    const web3 = this.helper.getWeb3();126    return new web3.eth.Contract(contractHelpersAbi as any, this.helper.getApi().consts.evmContractHelpers.contractAddress.toString(), {127      from: caller,128      gas: this.helper.eth.DEFAULT_GAS,129    });130  }131132  collectionHelpers(caller: string) {133    const web3 = this.helper.getWeb3();134    return new web3.eth.Contract(collectionHelpersAbi as any, this.helper.getApi().consts.common.contractAddress.toString(), {135      from: caller,136      gas: this.helper.eth.DEFAULT_GAS,137    });138  }139140  collection(address: string, mode: TCollectionMode, caller?: string, mergeDeprecated = false) {141    let abi;142    if(address === this.helper.ethAddress.fromCollectionId(0)) {143      abi = nativeFungibleAbi;144    } else {145      abi ={146        'nft': nonFungibleAbi,147        'rft': refungibleAbi,148        'ft': fungibleAbi,149      }[mode];150    }151    if(mergeDeprecated) {152      const deprecated = {153        'nft': nonFungibleDeprecatedAbi,154        'rft': refungibleDeprecatedAbi,155        'ft': fungibleDeprecatedAbi,156      }[mode];157      abi = [...abi, ...deprecated];158    }159    const web3 = this.helper.getWeb3();160    return new web3.eth.Contract(abi as any, address, {161      gas: this.helper.eth.DEFAULT_GAS,162      ...(caller ? {from: caller} : {}),163    });164  }165166  collectionById(collectionId: number, mode: 'nft' | 'rft' | 'ft', caller?: string, mergeDeprecated = false) {167    return this.collection(this.helper.ethAddress.fromCollectionId(collectionId), mode, caller, mergeDeprecated);168  }169170  rftToken(address: string, caller?: string, mergeDeprecated = false) {171    const web3 = this.helper.getWeb3();172    const abi = mergeDeprecated ? [...refungibleTokenAbi, ...refungibleTokenDeprecatedAbi] : refungibleTokenAbi;173    return new web3.eth.Contract(abi as any, address, {174      gas: this.helper.eth.DEFAULT_GAS,175      ...(caller ? {from: caller} : {}),176    });177  }178179  rftTokenById(collectionId: number, tokenId: number, caller?: string, mergeDeprecated = false) {180    return this.rftToken(this.helper.ethAddress.fromTokenId(collectionId, tokenId), caller, mergeDeprecated);181  }182}183184185class EthGroup extends EthGroupBase {186  DEFAULT_GAS = 2_500_000;187188  createAccount() {189    const web3 = this.helper.getWeb3();190    const account = web3.eth.accounts.create();191    web3.eth.accounts.wallet.add(account.privateKey);192    return account.address;193  }194195  async createAccountWithBalance(donor: IKeyringPair, amount = 600n) {196    const account = this.createAccount();197    await this.transferBalanceFromSubstrate(donor, account, amount);198199    return account;200  }201202  async transferBalanceFromSubstrate(donor: IKeyringPair, recepient: string, amount = 100n, inTokens = true) {203    return await this.helper.balance.transferToSubstrate(donor, evmToAddress(recepient), amount * (inTokens ? this.helper.balance.getOneTokenNominal() : 1n));204  }205206  async getCollectionCreationFee(signer: string) {207    const collectionHelper = await this.helper.ethNativeContract.collectionHelpers(signer);208    return await collectionHelper.methods.collectionCreationFee().call();209  }210211  async sendEVM(signer: IKeyringPair, contractAddress: string, abi: string, value: string, gasLimit?: number) {212    if(!gasLimit) gasLimit = this.DEFAULT_GAS;213    const web3 = this.helper.getWeb3();214    // FIXME: can't send legacy transaction using tx.evm.call215    const gasPrice = await web3.eth.getGasPrice();216    // TODO: check execution status217    await this.helper.executeExtrinsic(218      signer,219      'api.tx.evm.call', [this.helper.address.substrateToEth(signer.address), contractAddress, abi, value, gasLimit, gasPrice, null, null, []],220      true,221    );222  }223224  async callEVM(signer: TEthereumAccount, contractAddress: string, abi: string) {225    return await this.helper.callRpc('api.rpc.eth.call', [{from: signer, to: contractAddress, data: abi}]);226  }227228  createCollectionMethodName(mode: TCollectionMode) {229    switch (mode) {230      case 'ft':231        return 'createFTCollection';232      case 'nft':233        return 'createNFTCollection';234      case 'rft':235        return 'createRFTCollection';236    }237  }238239  async createCollection(mode: TCollectionMode, signer: string, name: string, description: string, tokenPrefix: string, decimals = 18, mergeDeprecated = false): Promise<{ collectionId: number, collectionAddress: string, events: NormalizedEvent[], collection: Contract }> {240    const collectionCreationPrice = this.helper.balance.getCollectionCreationPrice();241    const collectionHelper = await this.helper.ethNativeContract.collectionHelpers(signer);242    const functionName: string = this.createCollectionMethodName(mode);243244    const functionParams = mode === 'ft' ? [name, decimals, description, tokenPrefix] : [name, description, tokenPrefix];245    const result = await collectionHelper.methods[functionName](...functionParams).send({value: Number(collectionCreationPrice)});246247    const collectionAddress = this.helper.ethAddress.normalizeAddress(result.events.CollectionCreated.returnValues.collectionId);248    const collectionId = this.helper.ethAddress.extractCollectionId(collectionAddress);249    const events = this.helper.eth.normalizeEvents(result.events);250    const collection = await this.helper.ethNativeContract.collectionById(collectionId, mode, signer, mergeDeprecated);251252    return {collectionId, collectionAddress, events, collection};253  }254255  createNFTCollection(signer: string, name: string, description: string, tokenPrefix: string): Promise<{ collectionId: number, collectionAddress: string, events: NormalizedEvent[] }> {256    return this.createCollection('nft', signer, name, description, tokenPrefix);257  }258259  async createERC721MetadataCompatibleNFTCollection(signer: string, name: string, description: string, tokenPrefix: string, baseUri: string): Promise<{ collectionId: number, collectionAddress: string, events: NormalizedEvent[] }> {260    const collectionHelper = await this.helper.ethNativeContract.collectionHelpers(signer);261262    const {collectionId, collectionAddress, events} = await this.createCollection('nft', signer, name, description, tokenPrefix);263264    await collectionHelper.methods.makeCollectionERC721MetadataCompatible(collectionAddress, baseUri).send();265266    return {collectionId, collectionAddress, events};267  }268269  createRFTCollection(signer: string, name: string, description: string, tokenPrefix: string): Promise<{ collectionId: number, collectionAddress: string, events: NormalizedEvent[] }> {270    return this.createCollection('rft', signer, name, description, tokenPrefix);271  }272273  createFungibleCollection(signer: string, name: string, decimals: number, description: string, tokenPrefix: string): Promise<{ collectionId: number, collectionAddress: string, events: NormalizedEvent[] }> {274    return this.createCollection('ft', signer, name, description, tokenPrefix, decimals);275  }276277  async createERC721MetadataCompatibleRFTCollection(signer: string, name: string, description: string, tokenPrefix: string, baseUri: string): Promise<{ collectionId: number, collectionAddress: string, events: NormalizedEvent[] }> {278    const collectionHelper = await this.helper.ethNativeContract.collectionHelpers(signer);279280    const {collectionId, collectionAddress, events} = await this.createCollection('rft', signer, name, description, tokenPrefix);281282    await collectionHelper.methods.makeCollectionERC721MetadataCompatible(collectionAddress, baseUri).send();283284    return {collectionId, collectionAddress, events};285  }286287  async deployCollectorContract(signer: string): Promise<Contract> {288    return await this.helper.ethContract.deployByCode(signer, 'Collector', `289    // SPDX-License-Identifier: UNLICENSED290    pragma solidity ^0.8.6;291292    contract Collector {293      uint256 collected;294      fallback() external payable {295        giveMoney();296      }297      function giveMoney() public payable {298        collected += msg.value;299      }300      function getCollected() public view returns (uint256) {301        return collected;302      }303      function getUnaccounted() public view returns (uint256) {304        return address(this).balance - collected;305      }306307      function withdraw(address payable target) public {308        target.transfer(collected);309        collected = 0;310      }311    }312  `);313  }314315  async deployFlipper(signer: string): Promise<Contract> {316    return await this.helper.ethContract.deployByCode(signer, 'Flipper', `317    // SPDX-License-Identifier: UNLICENSED318    pragma solidity ^0.8.6;319320    contract Flipper {321      bool value = false;322      function flip() public {323        value = !value;324      }325      function getValue() public view returns (bool) {326        return value;327      }328    }329  `);330  }331332  async recordCallFee(user: string, call: () => Promise<any>): Promise<bigint> {333    const before = await this.helper.balance.getEthereum(user);334    await call();335    // In dev mode, the transaction might not finish processing in time336    await this.helper.wait.newBlocks(1);337    const after = await this.helper.balance.getEthereum(user);338339    return before - after;340  }341342  normalizeEvents(events: any): NormalizedEvent[] {343    const output = [];344    for(const key of Object.keys(events)) {345      if(key.match(/^[0-9]+$/)) {346        output.push(events[key]);347      } else if(Array.isArray(events[key])) {348        output.push(...events[key]);349      } else {350        output.push(events[key]);351      }352    }353    output.sort((a, b) => a.logIndex - b.logIndex);354    return output.map(({address, event, returnValues}) => {355      const args: { [key: string]: string } = {};356      for(const key of Object.keys(returnValues)) {357        if(!key.match(/^[0-9]+$/)) {358          args[key] = returnValues[key];359        }360      }361      return {362        address,363        event,364        args,365      };366    });367  }368369  async calculateFee(address: ICrossAccountId, code: () => Promise<any>): Promise<bigint> {370    const wrappedCode = async () => {371      await code();372      // In dev mode, the transaction might not finish processing in time373      await this.helper.wait.newBlocks(1);374    };375    return await this.helper.arrange.calculcateFee(address, wrappedCode);376  }377}378379class EthAddressGroup extends EthGroupBase {380  extractCollectionId(address: string): number {381    if(!(address.length === 42 || address.length === 40)) throw new Error('address wrong format');382    return parseInt(address.slice(address.length - 8), 16);383  }384385  fromCollectionId(collectionId: number): string {386    if(collectionId >= 0xffffffff || collectionId < 0) throw new Error('collectionId overflow');387    return Web3.utils.toChecksumAddress(`0x17c4e6453cc49aaaaeaca894e6d9683e${collectionId.toString(16).padStart(8, '0')}`);388  }389390  extractTokenId(address: string): { collectionId: number, tokenId: number } {391    if(!address.startsWith('0x'))392      throw 'address not starts with "0x"';393    if(address.length > 42)394      throw 'address length is more than 20 bytes';395    return {396      collectionId: Number('0x' + address.substring(address.length - 16, address.length - 8)),397      tokenId: Number('0x' + address.substring(address.length - 8)),398    };399  }400401  fromTokenId(collectionId: number, tokenId: number): string {402    return this.helper.util.getTokenAddress({collectionId, tokenId});403  }404405  normalizeAddress(address: string): string {406    return '0x' + address.substring(address.length - 40);407  }408}409export class EthPropertyGroup extends EthGroupBase {410  property(key: string, value: string): EthProperty {411    return [412      key,413      '0x' + Buffer.from(value).toString('hex'),414    ];415  }416}417export type EthUniqueHelperConstructor = new (...args: any[]) => EthUniqueHelper;418419export class EthCrossAccountGroup extends EthGroupBase {420  createAccount(): CrossAddress {421    return this.fromAddress(this.helper.eth.createAccount());422  }423424  async createAccountWithBalance(donor: IKeyringPair, amount = 100n) {425    return this.fromAddress(await this.helper.eth.createAccountWithBalance(donor, amount));426  }427428  fromAddress(address: TEthereumAccount): CrossAddress {429    return {430      eth: address,431      sub: '0',432    };433  }434435  fromKeyringPair(keyring: IKeyringPair): CrossAddress {436    return {437      eth: '0x0000000000000000000000000000000000000000',438      sub: keyring.addressRaw,439    };440  }441}442443export class FeeGas {444  fee: number | bigint = 0n;445446  gas: number | bigint = 0n;447448  public static async build(helper: EthUniqueHelper, fee: bigint): Promise<FeeGas> {449    const instance = new FeeGas();450    instance.fee = instance.convertToTokens(fee);451    instance.gas = await instance.convertToGas(fee, helper);452    return instance;453  }454455  private async convertToGas(fee: bigint, helper: EthUniqueHelper): Promise<bigint> {456    const gasPrice = BigInt(await helper.getWeb3().eth.getGasPrice());457    return fee / gasPrice;458  }459460  private convertToTokens(value: bigint, nominal = 1_000_000_000_000_000_000n): number {461    return Number((value * 1000n) / nominal) / 1000;462  }463}464465class EthArrangeGroup extends ArrangeGroup {466  helper: EthUniqueHelper;467468  constructor(helper: EthUniqueHelper) {469    super(helper);470    this.helper = helper;471  }472473  async calculcateFeeGas(payer: ICrossAccountId, promise: () => Promise<any>): Promise<FeeGas> {474    const fee = await this.calculcateFee(payer, promise);475    return await FeeGas.build(this.helper, fee);476  }477}478export class EthUniqueHelper extends DevUniqueHelper {479  web3: Web3 | null = null;480  web3Provider: WebsocketProvider | null = null;481482  eth: EthGroup;483  ethAddress: EthAddressGroup;484  ethCrossAccount: EthCrossAccountGroup;485  ethNativeContract: NativeContractGroup;486  ethContract: ContractGroup;487  ethProperty: EthPropertyGroup;488  arrange: EthArrangeGroup;489  constructor(logger: { log: (msg: any, level: any) => void, level: any }, options: { [key: string]: any } = {}) {490    options.helperBase = options.helperBase ?? EthUniqueHelper;491492    super(logger, options);493    this.eth = new EthGroup(this);494    this.ethAddress = new EthAddressGroup(this);495    this.ethCrossAccount = new EthCrossAccountGroup(this);496    this.ethNativeContract = new NativeContractGroup(this);497    this.ethContract = new ContractGroup(this);498    this.ethProperty = new EthPropertyGroup(this);499    this.arrange = new EthArrangeGroup(this);500    super.arrange = this.arrange;501  }502503  getWeb3(): Web3 {504    if(this.web3 === null) throw Error('Web3 not connected');505    return this.web3;506  }507508  connectWeb3(wsEndpoint: string) {509    if(this.web3 !== null) return;510    this.web3Provider = new Web3.providers.WebsocketProvider(wsEndpoint);511    this.web3 = new Web3(this.web3Provider);512  }513514  async disconnect() {515    if(this.web3 === null) return;516    this.web3Provider?.connection.close();517518    await super.disconnect();519  }520521  clearApi() {522    super.clearApi();523    this.web3 = null;524  }525526  clone(helperCls: EthUniqueHelperConstructor, options?: { [key: string]: any; }): EthUniqueHelper {527    const newHelper = super.clone(helperCls, options) as EthUniqueHelper;528    newHelper.web3 = this.web3;529    newHelper.web3Provider = this.web3Provider;530531    return newHelper;532  }533}