git.delta.rocks / unique-network / refs/commits / 369a51ded768

difftreelog

source

tests/src/eth/util/playgrounds/unique.dev.ts21.4 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, CollectionMode, CreateCollectionData} 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}5253class ContractGroup extends EthGroupBase {54  async findImports(imports?: ContractImports[]) {55    if(!imports) return function(path: string) {56      return {error: `File not found: ${path}`};57    };5859    const knownImports = {} as { [key: string]: string };60    for(const imp of imports) {61      knownImports[imp.solPath] = (await readFile(imp.fsPath)).toString();62    }6364    return function(path: string) {65      if(path in knownImports) return {contents: knownImports[path]};66      return {error: `File not found: ${path}`};67    };68  }6970  async compile(name: string, src: string, imports?: ContractImports[]): Promise<CompiledContract> {71    const compiled = JSON.parse(solc.compile(JSON.stringify({72      language: 'Solidity',73      sources: {74        [`${name}.sol`]: {75          content: src,76        },77      },78      settings: {79        outputSelection: {80          '*': {81            '*': ['*'],82          },83        },84      },85    }), {import: await this.findImports(imports)}));8687    const hasErrors = compiled['errors']88      && compiled['errors'].length > 089      && compiled.errors.some(function(err: any) {90        return err.severity == 'error';91      });9293    if(hasErrors) {94      throw compiled.errors;95    }96    const out = compiled.contracts[`${name}.sol`][name];9798    return {99      abi: out.abi,100      object: '0x' + out.evm.bytecode.object,101    };102  }103104  async deployByCode(signer: string, name: string, src: string, imports?: ContractImports[], gas?: number, args?: any[]): Promise<Contract> {105    const compiledContract = await this.compile(name, src, imports);106    return this.deployByAbi(signer, compiledContract.abi, compiledContract.object, gas, args);107  }108109  async deployByAbi(signer: string, abi: any, object: string, gas?: number, args?: any[]): Promise<Contract> {110    const web3 = this.helper.getWeb3();111    const contract = new web3.eth.Contract(abi, undefined, {112      data: object,113      from: signer,114      gas: gas ?? this.helper.eth.DEFAULT_GAS,115    });116    return await contract.deploy({data: object, arguments: args}).send({from: signer});117  }118119}120121class NativeContractGroup extends EthGroupBase {122123  contractHelpers(caller: string) {124    const web3 = this.helper.getWeb3();125    return new web3.eth.Contract(contractHelpersAbi as any, this.helper.getApi().consts.evmContractHelpers.contractAddress.toString(), {126      from: caller,127      gas: this.helper.eth.DEFAULT_GAS,128    });129  }130131  collectionHelpers(caller: string) {132    const web3 = this.helper.getWeb3();133    return new web3.eth.Contract(collectionHelpersAbi as any, this.helper.getApi().consts.common.contractAddress.toString(), {134      from: caller,135      gas: this.helper.eth.DEFAULT_GAS,136    });137  }138139  collection(address: string, mode: TCollectionMode, caller?: string, mergeDeprecated = false) {140    let abi;141    if(address === this.helper.ethAddress.fromCollectionId(0)) {142      abi = nativeFungibleAbi;143    } else {144      abi ={145        'nft': nonFungibleAbi,146        'rft': refungibleAbi,147        'ft': fungibleAbi,148      }[mode];149    }150    if(mergeDeprecated) {151      const deprecated = {152        'nft': nonFungibleDeprecatedAbi,153        'rft': refungibleDeprecatedAbi,154        'ft': fungibleDeprecatedAbi,155      }[mode];156      abi = [...abi, ...deprecated];157    }158    const web3 = this.helper.getWeb3();159    return new web3.eth.Contract(abi as any, address, {160      gas: this.helper.eth.DEFAULT_GAS,161      ...(caller ? {from: caller} : {}),162    });163  }164165  collectionById(collectionId: number, mode: 'nft' | 'rft' | 'ft', caller?: string, mergeDeprecated = false) {166    return this.collection(this.helper.ethAddress.fromCollectionId(collectionId), mode, caller, mergeDeprecated);167  }168169  rftToken(address: string, caller?: string, mergeDeprecated = false) {170    const web3 = this.helper.getWeb3();171    const abi = mergeDeprecated ? [...refungibleTokenAbi, ...refungibleTokenDeprecatedAbi] : refungibleTokenAbi;172    return new web3.eth.Contract(abi as any, address, {173      gas: this.helper.eth.DEFAULT_GAS,174      ...(caller ? {from: caller} : {}),175    });176  }177178  rftTokenById(collectionId: number, tokenId: number, caller?: string, mergeDeprecated = false) {179    return this.rftToken(this.helper.ethAddress.fromTokenId(collectionId, tokenId), caller, mergeDeprecated);180  }181}182183class CreateCollectionTransaction {184  signer: string;185  data: CreateCollectionData;186  mergeDeprecated: boolean;187  helper: EthUniqueHelper;188189  constructor(helper: EthUniqueHelper, signer: string, data: CreateCollectionData, mergeDeprecated = false) {190    this.helper = helper;191    this.signer = signer;192193    let flags = 0;194    // convert CollectionFlags to number and join them in one number195    if(!data.flags || typeof data.flags == 'number') {196      flags = data.flags ?? 0;197    } else {198      for(let i = 0; i < data.flags.length; i++){199        const flag = data.flags[i];200        flags = flags | flag;201      }202    }203    data.flags = flags;204205    this.data = data;206    this.mergeDeprecated = mergeDeprecated;207  }208209  private async createTransaction() {210    const collectionHelper = this.helper.ethNativeContract.collectionHelpers(this.signer);211    let collectionMode;212    switch (this.data.collectionMode) {213      case 'nft': collectionMode = CollectionMode.Nonfungible; break;214      case 'rft': collectionMode = CollectionMode.Refungible; break;215      case 'ft': collectionMode = CollectionMode.Fungible; break;216    }217218    const tx = collectionHelper.methods.createCollection([219      this.data.name,220      this.data.description,221      this.data.tokenPrefix,222      collectionMode,223      this.data.decimals,224      this.data.properties,225      this.data.tokenPropertyPermissions,226      this.data.adminList,227      this.data.nestingSettings,228      this.data.limits,229      this.data.pendingSponsor,230      this.data.flags,231    ]);232    return tx;233  }234235  async send(options?: any): Promise<{ collectionId: number, collectionAddress: string, events: NormalizedEvent[], collection: Contract }> {236    const collectionCreationPrice = {237      value: Number(this.helper.balance.getCollectionCreationPrice()),238    };239    const tx = await this.createTransaction();240    const result = await tx.send({...options, ...collectionCreationPrice});241242    const collectionAddress = this.helper.ethAddress.normalizeAddress(result.events.CollectionCreated.returnValues.collectionId);243    const collectionId = this.helper.ethAddress.extractCollectionId(collectionAddress);244    const events = this.helper.eth.normalizeEvents(result.events);245    const collection = await this.helper.ethNativeContract.collectionById(collectionId, this.data.collectionMode, this.signer, this.mergeDeprecated);246247    return {collectionId, collectionAddress, events, collection};248  }249250  async call(options?: any) {251    const collectionCreationPrice = {252      value: Number(this.helper.balance.getCollectionCreationPrice()),253    };254    const tx = await this.createTransaction();255256    return await tx.call({...options, ...collectionCreationPrice});257  }258}259260261class EthGroup extends EthGroupBase {262  DEFAULT_GAS = 2_500_000;263264  createAccount() {265    const web3 = this.helper.getWeb3();266    const account = web3.eth.accounts.create();267    web3.eth.accounts.wallet.add(account.privateKey);268    return account.address;269  }270271  async createAccountWithBalance(donor: IKeyringPair, amount = 600n) {272    const account = this.createAccount();273    await this.transferBalanceFromSubstrate(donor, account, amount);274275    return account;276  }277278  async transferBalanceFromSubstrate(donor: IKeyringPair, recepient: string, amount = 100n, inTokens = true) {279    return await this.helper.balance.transferToSubstrate(donor, evmToAddress(recepient), amount * (inTokens ? this.helper.balance.getOneTokenNominal() : 1n));280  }281282  async getCollectionCreationFee(signer: string) {283    const collectionHelper = await this.helper.ethNativeContract.collectionHelpers(signer);284    return await collectionHelper.methods.collectionCreationFee().call();285  }286287  async sendEVM(signer: IKeyringPair, contractAddress: string, abi: string, value: string, gasLimit?: number) {288    if(!gasLimit) gasLimit = this.DEFAULT_GAS;289    const web3 = this.helper.getWeb3();290    // FIXME: can't send legacy transaction using tx.evm.call291    const gasPrice = await web3.eth.getGasPrice();292    // TODO: check execution status293    await this.helper.executeExtrinsic(294      signer,295      'api.tx.evm.call', [this.helper.address.substrateToEth(signer.address), contractAddress, abi, value, gasLimit, gasPrice, null, null, []],296      true,297    );298  }299300  async callEVM(signer: TEthereumAccount, contractAddress: string, abi: string) {301    return await this.helper.callRpc('api.rpc.eth.call', [{from: signer, to: contractAddress, data: abi}]);302  }303304  createCollectionMethodName(mode: TCollectionMode) {305    switch (mode) {306      case 'ft':307        return 'createFTCollection';308      case 'nft':309        return 'createNFTCollection';310      case 'rft':311        return 'createRFTCollection';312    }313  }314315  createCollection(signer: string, data: CreateCollectionData, mergeDeprecated = false): CreateCollectionTransaction {316    return new CreateCollectionTransaction(this.helper, signer, data, mergeDeprecated);317  }318319  createNFTCollection(signer: string, name: string, description: string, tokenPrefix: string): Promise<{ collectionId: number, collectionAddress: string, events: NormalizedEvent[] }> {320    return this.createCollection(signer, new CreateCollectionData(name, description, tokenPrefix, 'nft')).send();321  }322323  async createERC721MetadataCompatibleCollection(signer: string, data: CreateCollectionData, baseUri: string): Promise<{ collectionId: number, collectionAddress: string, events: NormalizedEvent[] }> {324    const collectionHelper = await this.helper.ethNativeContract.collectionHelpers(signer);325326    const {collectionId, collectionAddress, events} = await this.createCollection(signer, data).send();327328    await collectionHelper.methods.makeCollectionERC721MetadataCompatible(collectionAddress, baseUri).send();329330    return {collectionId, collectionAddress, events};331  }332333  async createERC721MetadataCompatibleNFTCollection(signer: string, name: string, description: string, tokenPrefix: string, baseUri: string): Promise<{ collectionId: number, collectionAddress: string, events: NormalizedEvent[] }> {334    const collectionHelper = await this.helper.ethNativeContract.collectionHelpers(signer);335336    const {collectionId, collectionAddress, events} = await this.createCollection(signer, new CreateCollectionData(name, description, tokenPrefix, 'nft')).send();337338    await collectionHelper.methods.makeCollectionERC721MetadataCompatible(collectionAddress, baseUri).send();339340    return {collectionId, collectionAddress, events};341  }342343  createRFTCollection(signer: string, name: string, description: string, tokenPrefix: string): Promise<{ collectionId: number, collectionAddress: string, events: NormalizedEvent[] }> {344    return this.createCollection(signer, new CreateCollectionData(name, description, tokenPrefix, 'rft')).send();345  }346347  createFungibleCollection(signer: string, name: string, decimals: number, description: string, tokenPrefix: string): Promise<{ collectionId: number, collectionAddress: string, events: NormalizedEvent[] }> {348    return this.createCollection(signer, new CreateCollectionData(name, description, tokenPrefix, 'ft')).send();349  }350351  async createERC721MetadataCompatibleRFTCollection(signer: string, name: string, description: string, tokenPrefix: string, baseUri: string): Promise<{ collectionId: number, collectionAddress: string, events: NormalizedEvent[] }> {352    const collectionHelper = await this.helper.ethNativeContract.collectionHelpers(signer);353354    const {collectionId, collectionAddress, events} = await this.createCollection(signer, new CreateCollectionData(name, description, tokenPrefix, 'rft')).send();355356    await collectionHelper.methods.makeCollectionERC721MetadataCompatible(collectionAddress, baseUri).send();357358    return {collectionId, collectionAddress, events};359  }360361  async deployCollectorContract(signer: string): Promise<Contract> {362    return await this.helper.ethContract.deployByCode(signer, 'Collector', `363    // SPDX-License-Identifier: UNLICENSED364    pragma solidity ^0.8.6;365366    contract Collector {367      uint256 collected;368      fallback() external payable {369        giveMoney();370      }371      function giveMoney() public payable {372        collected += msg.value;373      }374      function getCollected() public view returns (uint256) {375        return collected;376      }377      function getUnaccounted() public view returns (uint256) {378        return address(this).balance - collected;379      }380381      function withdraw(address payable target) public {382        target.transfer(collected);383        collected = 0;384      }385    }386  `);387  }388389  async deployFlipper(signer: string): Promise<Contract> {390    return await this.helper.ethContract.deployByCode(signer, 'Flipper', `391    // SPDX-License-Identifier: UNLICENSED392    pragma solidity ^0.8.6;393394    contract Flipper {395      bool value = false;396      function flip() public {397        value = !value;398      }399      function getValue() public view returns (bool) {400        return value;401      }402    }403  `);404  }405406  async recordCallFee(user: string, call: () => Promise<any>): Promise<bigint> {407    const before = await this.helper.balance.getEthereum(user);408    await call();409    // In dev mode, the transaction might not finish processing in time410    await this.helper.wait.newBlocks(1);411    const after = await this.helper.balance.getEthereum(user);412413    return before - after;414  }415416  normalizeEvents(events: any): NormalizedEvent[] {417    const output = [];418    for(const key of Object.keys(events)) {419      if(key.match(/^[0-9]+$/)) {420        output.push(events[key]);421      } else if(Array.isArray(events[key])) {422        output.push(...events[key]);423      } else {424        output.push(events[key]);425      }426    }427    output.sort((a, b) => a.logIndex - b.logIndex);428    return output.map(({address, event, returnValues}) => {429      const args: { [key: string]: string } = {};430      for(const key of Object.keys(returnValues)) {431        if(!key.match(/^[0-9]+$/)) {432          args[key] = returnValues[key];433        }434      }435      return {436        address,437        event,438        args,439      };440    });441  }442443  async calculateFee(address: ICrossAccountId, code: () => Promise<any>): Promise<bigint> {444    const wrappedCode = async () => {445      await code();446      // In dev mode, the transaction might not finish processing in time447      await this.helper.wait.newBlocks(1);448    };449    return await this.helper.arrange.calculcateFee(address, wrappedCode);450  }451}452453class EthAddressGroup extends EthGroupBase {454  extractCollectionId(address: string): number {455    if(!(address.length === 42 || address.length === 40)) throw new Error('address wrong format');456    return parseInt(address.slice(address.length - 8), 16);457  }458459  fromCollectionId(collectionId: number): string {460    if(collectionId >= 0xffffffff || collectionId < 0) throw new Error('collectionId overflow');461    return Web3.utils.toChecksumAddress(`0x17c4e6453cc49aaaaeaca894e6d9683e${collectionId.toString(16).padStart(8, '0')}`);462  }463464  extractTokenId(address: string): { collectionId: number, tokenId: number } {465    if(!address.startsWith('0x'))466      throw 'address not starts with "0x"';467    if(address.length > 42)468      throw 'address length is more than 20 bytes';469    return {470      collectionId: Number('0x' + address.substring(address.length - 16, address.length - 8)),471      tokenId: Number('0x' + address.substring(address.length - 8)),472    };473  }474475  fromTokenId(collectionId: number, tokenId: number): string {476    return this.helper.util.getTokenAddress({collectionId, tokenId});477  }478479  normalizeAddress(address: string): string {480    return '0x' + address.substring(address.length - 40);481  }482}483export class EthPropertyGroup extends EthGroupBase {484  property(key: string, value: string): EthProperty {485    return [486      key,487      '0x' + Buffer.from(value).toString('hex'),488    ];489  }490}491export type EthUniqueHelperConstructor = new (...args: any[]) => EthUniqueHelper;492493export class EthCrossAccountGroup extends EthGroupBase {494  createAccount(): CrossAddress {495    return this.fromAddress(this.helper.eth.createAccount());496  }497498  async createAccountWithBalance(donor: IKeyringPair, amount = 100n) {499    return this.fromAddress(await this.helper.eth.createAccountWithBalance(donor, amount));500  }501502  fromAddress(address: TEthereumAccount): CrossAddress {503    return {504      eth: address,505      sub: '0',506    };507  }508509  fromAddr(address: TEthereumAccount): [string, string] {510    return [511      address,512      '0',513    ];514  }515516  fromKeyringPair(keyring: IKeyringPair): CrossAddress {517    return {518      eth: '0x0000000000000000000000000000000000000000',519      sub: keyring.addressRaw,520    };521  }522}523524export class FeeGas {525  fee: number | bigint = 0n;526527  gas: number | bigint = 0n;528529  public static async build(helper: EthUniqueHelper, fee: bigint): Promise<FeeGas> {530    const instance = new FeeGas();531    instance.fee = instance.convertToTokens(fee);532    instance.gas = await instance.convertToGas(fee, helper);533    return instance;534  }535536  private async convertToGas(fee: bigint, helper: EthUniqueHelper): Promise<bigint> {537    const gasPrice = BigInt(await helper.getWeb3().eth.getGasPrice());538    return fee / gasPrice;539  }540541  private convertToTokens(value: bigint, nominal = 1_000_000_000_000_000_000n): number {542    return Number((value * 1000n) / nominal) / 1000;543  }544}545546class EthArrangeGroup extends ArrangeGroup {547  helper: EthUniqueHelper;548549  constructor(helper: EthUniqueHelper) {550    super(helper);551    this.helper = helper;552  }553554  async calculcateFeeGas(payer: ICrossAccountId, promise: () => Promise<any>): Promise<FeeGas> {555    const fee = await this.calculcateFee(payer, promise);556    return await FeeGas.build(this.helper, fee);557  }558}559export class EthUniqueHelper extends DevUniqueHelper {560  web3: Web3 | null = null;561  web3Provider: WebsocketProvider | null = null;562563  eth: EthGroup;564  ethAddress: EthAddressGroup;565  ethCrossAccount: EthCrossAccountGroup;566  ethNativeContract: NativeContractGroup;567  ethContract: ContractGroup;568  ethProperty: EthPropertyGroup;569  arrange: EthArrangeGroup;570  constructor(logger: { log: (msg: any, level: any) => void, level: any }, options: { [key: string]: any } = {}) {571    options.helperBase = options.helperBase ?? EthUniqueHelper;572573    super(logger, options);574    this.eth = new EthGroup(this);575    this.ethAddress = new EthAddressGroup(this);576    this.ethCrossAccount = new EthCrossAccountGroup(this);577    this.ethNativeContract = new NativeContractGroup(this);578    this.ethContract = new ContractGroup(this);579    this.ethProperty = new EthPropertyGroup(this);580    this.arrange = new EthArrangeGroup(this);581    super.arrange = this.arrange;582  }583584  getWeb3(): Web3 {585    if(this.web3 === null) throw Error('Web3 not connected');586    return this.web3;587  }588589  connectWeb3(wsEndpoint: string) {590    if(this.web3 !== null) return;591    this.web3Provider = new Web3.providers.WebsocketProvider(wsEndpoint);592    this.web3 = new Web3(this.web3Provider);593  }594595  async disconnect() {596    if(this.web3 === null) return;597    this.web3Provider?.connection.close();598599    await super.disconnect();600  }601602  clearApi() {603    super.clearApi();604    this.web3 = null;605  }606607  clone(helperCls: EthUniqueHelperConstructor, options?: { [key: string]: any; }): EthUniqueHelper {608    const newHelper = super.clone(helperCls, options) as EthUniqueHelper;609    newHelper.web3 = this.web3;610    newHelper.web3Provider = this.web3Provider;611612    return newHelper;613  }614}