git.delta.rocks / unique-network / refs/commits / dba471697970

difftreelog

source

js-packages/tests/src/eth/util/playgrounds/unique.dev.ts21.5 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 type {IKeyringPair} from '@polkadot/types/types';1819import {ArrangeGroup, DevUniqueHelper} from '@unique/playgrounds/src/unique.dev.js';2021import type {ContractImports, CompiledContract, CrossAddress, NormalizedEvent, EthProperty} from './types.js';22import {CollectionMode, CreateCollectionData} from './types.js';2324// Native contracts ABI25import collectionHelpersAbi from '../../abi/collectionHelpers.json' assert {type: 'json'};26import nativeFungibleAbi from '../../abi/nativeFungible.json' assert {type: 'json'};27import fungibleAbi from '../../abi/fungible.json' assert {type: 'json'};28import fungibleDeprecatedAbi from '../../abi/fungibleDeprecated.json' assert {type: 'json'};29import nonFungibleAbi from '../../abi/nonFungible.json' assert {type: 'json'};30import nonFungibleDeprecatedAbi from '../../abi/nonFungibleDeprecated.json' assert {type: 'json'};31import refungibleAbi from '../../abi/reFungible.json' assert {type: 'json'};32import refungibleDeprecatedAbi from '../../abi/reFungibleDeprecated.json' assert {type: 'json'};33import refungibleTokenAbi from '../../abi/reFungibleToken.json' assert {type: 'json'};34import refungibleTokenDeprecatedAbi from '../../abi/reFungibleTokenDeprecated.json' assert {type: 'json'};35import contractHelpersAbi from '../../abi/contractHelpers.json' assert {type: 'json'};36import type {ICrossAccountId, TEthereumAccount, TCollectionMode} from '@unique/playgrounds/src/types.js';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) {196      flags = 0;197    } else if(typeof data.flags == 'number') {198      flags = data.flags;199    } else {200      for(let i = 0; i < data.flags.length; i++){201        const flag = data.flags[i];202        flags = flags | flag;203      }204    }205    data.flags = flags;206207    this.data = data;208    this.mergeDeprecated = mergeDeprecated;209  }210211  // eslint-disable-next-line require-await212  private async createTransaction() {213    const collectionHelper = this.helper.ethNativeContract.collectionHelpers(this.signer);214    let collectionMode;215    switch (this.data.collectionMode) {216      case 'nft': collectionMode = CollectionMode.Nonfungible; break;217      case 'rft': collectionMode = CollectionMode.Refungible; break;218      case 'ft': collectionMode = CollectionMode.Fungible; break;219    }220221    const tx = collectionHelper.methods.createCollection([222      this.data.name,223      this.data.description,224      this.data.tokenPrefix,225      collectionMode,226      this.data.decimals,227      this.data.properties,228      this.data.tokenPropertyPermissions,229      this.data.adminList,230      this.data.nestingSettings,231      this.data.limits,232      this.data.pendingSponsor,233      this.data.flags,234    ]);235    return tx;236  }237238  async send(options?: any): Promise<{ collectionId: number, collectionAddress: string, events: NormalizedEvent[], collection: Contract }> {239    const collectionCreationPrice = {240      value: Number(this.helper.balance.getCollectionCreationPrice()),241    };242    const tx = await this.createTransaction();243    const result = await tx.send({...options, ...collectionCreationPrice});244245    const collectionAddress = this.helper.ethAddress.normalizeAddress(result.events.CollectionCreated.returnValues.collectionId);246    const collectionId = this.helper.ethAddress.extractCollectionId(collectionAddress);247    const events = this.helper.eth.normalizeEvents(result.events);248    const collection = await this.helper.ethNativeContract.collectionById(collectionId, this.data.collectionMode, this.signer, this.mergeDeprecated);249250    return {collectionId, collectionAddress, events, collection};251  }252253  async call(options?: any) {254    const collectionCreationPrice = {255      value: Number(this.helper.balance.getCollectionCreationPrice()),256    };257    const tx = await this.createTransaction();258259    return await tx.call({...options, ...collectionCreationPrice});260  }261}262263264class EthGroup extends EthGroupBase {265  DEFAULT_GAS = 2_500_000;266267  createAccount() {268    const web3 = this.helper.getWeb3();269    const account = web3.eth.accounts.create();270    web3.eth.accounts.wallet.add(account.privateKey);271    return account.address;272  }273274  async createAccountWithBalance(donor: IKeyringPair, amount = 600n) {275    const account = this.createAccount();276    await this.transferBalanceFromSubstrate(donor, account, amount);277278    return account;279  }280281  async transferBalanceFromSubstrate(donor: IKeyringPair, recepient: string, amount = 100n, inTokens = true) {282    return await this.helper.balance.transferToSubstrate(donor, evmToAddress(recepient), amount * (inTokens ? this.helper.balance.getOneTokenNominal() : 1n));283  }284285  async getCollectionCreationFee(signer: string) {286    const collectionHelper = await this.helper.ethNativeContract.collectionHelpers(signer);287    return await collectionHelper.methods.collectionCreationFee().call();288  }289290  async sendEVM(signer: IKeyringPair, contractAddress: string, abi: string, value: string, gasLimit?: number) {291    if(!gasLimit) gasLimit = this.DEFAULT_GAS;292    const web3 = this.helper.getWeb3();293    // FIXME: can't send legacy transaction using tx.evm.call294    const gasPrice = await web3.eth.getGasPrice();295    // TODO: check execution status296    await this.helper.executeExtrinsic(297      signer,298      'api.tx.evm.call', [this.helper.address.substrateToEth(signer.address), contractAddress, abi, value, gasLimit, gasPrice, null, null, []],299      true,300    );301  }302303  async callEVM(signer: TEthereumAccount, contractAddress: string, abi: string) {304    return await this.helper.callRpc('api.rpc.eth.call', [{from: signer, to: contractAddress, data: abi}]);305  }306307  createCollectionMethodName(mode: TCollectionMode) {308    switch (mode) {309      case 'ft':310        return 'createFTCollection';311      case 'nft':312        return 'createNFTCollection';313      case 'rft':314        return 'createRFTCollection';315    }316  }317318  createCollection(signer: string, data: CreateCollectionData, mergeDeprecated = false): CreateCollectionTransaction {319    return new CreateCollectionTransaction(this.helper, signer, data, mergeDeprecated);320  }321322  createNFTCollection(signer: string, name: string, description: string, tokenPrefix: string): Promise<{ collectionId: number, collectionAddress: string, events: NormalizedEvent[] }> {323    return this.createCollection(signer, new CreateCollectionData(name, description, tokenPrefix, 'nft')).send();324  }325326  async createERC721MetadataCompatibleCollection(signer: string, data: CreateCollectionData, baseUri: string): Promise<{ collectionId: number, collectionAddress: string, events: NormalizedEvent[] }> {327    const collectionHelper = await this.helper.ethNativeContract.collectionHelpers(signer);328329    const {collectionId, collectionAddress, events} = await this.createCollection(signer, data).send();330331    await collectionHelper.methods.makeCollectionERC721MetadataCompatible(collectionAddress, baseUri).send();332333    return {collectionId, collectionAddress, events};334  }335336  async createERC721MetadataCompatibleNFTCollection(signer: string, name: string, description: string, tokenPrefix: string, baseUri: string): Promise<{ collectionId: number, collectionAddress: string, events: NormalizedEvent[] }> {337    const collectionHelper = await this.helper.ethNativeContract.collectionHelpers(signer);338339    const {collectionId, collectionAddress, events} = await this.createCollection(signer, new CreateCollectionData(name, description, tokenPrefix, 'nft')).send();340341    await collectionHelper.methods.makeCollectionERC721MetadataCompatible(collectionAddress, baseUri).send();342343    return {collectionId, collectionAddress, events};344  }345346  createRFTCollection(signer: string, name: string, description: string, tokenPrefix: string): Promise<{ collectionId: number, collectionAddress: string, events: NormalizedEvent[] }> {347    return this.createCollection(signer, new CreateCollectionData(name, description, tokenPrefix, 'rft')).send();348  }349350  createFungibleCollection(signer: string, name: string, _decimals: number, description: string, tokenPrefix: string): Promise<{ collectionId: number, collectionAddress: string, events: NormalizedEvent[] }> {351    return this.createCollection(signer, new CreateCollectionData(name, description, tokenPrefix, 'ft')).send();352  }353354  async createERC721MetadataCompatibleRFTCollection(signer: string, name: string, description: string, tokenPrefix: string, baseUri: string): Promise<{ collectionId: number, collectionAddress: string, events: NormalizedEvent[] }> {355    const collectionHelper = await this.helper.ethNativeContract.collectionHelpers(signer);356357    const {collectionId, collectionAddress, events} = await this.createCollection(signer, new CreateCollectionData(name, description, tokenPrefix, 'rft')).send();358359    await collectionHelper.methods.makeCollectionERC721MetadataCompatible(collectionAddress, baseUri).send();360361    return {collectionId, collectionAddress, events};362  }363364  async deployCollectorContract(signer: string): Promise<Contract> {365    return await this.helper.ethContract.deployByCode(signer, 'Collector', `366    // SPDX-License-Identifier: UNLICENSED367    pragma solidity ^0.8.6;368369    contract Collector {370      uint256 collected;371      fallback() external payable {372        giveMoney();373      }374      function giveMoney() public payable {375        collected += msg.value;376      }377      function getCollected() public view returns (uint256) {378        return collected;379      }380      function getUnaccounted() public view returns (uint256) {381        return address(this).balance - collected;382      }383384      function withdraw(address payable target) public {385        target.transfer(collected);386        collected = 0;387      }388    }389  `);390  }391392  async deployFlipper(signer: string): Promise<Contract> {393    return await this.helper.ethContract.deployByCode(signer, 'Flipper', `394    // SPDX-License-Identifier: UNLICENSED395    pragma solidity ^0.8.6;396397    contract Flipper {398      bool value = false;399      function flip() public {400        value = !value;401      }402      function getValue() public view returns (bool) {403        return value;404      }405    }406  `);407  }408409  async recordCallFee(user: string, call: () => Promise<any>): Promise<bigint> {410    const before = await this.helper.balance.getEthereum(user);411    await call();412    // In dev mode, the transaction might not finish processing in time413    await this.helper.wait.newBlocks(1);414    const after = await this.helper.balance.getEthereum(user);415416    return before - after;417  }418419  normalizeEvents(events: any): NormalizedEvent[] {420    const output = [];421    for(const key of Object.keys(events)) {422      if(key.match(/^[0-9]+$/)) {423        output.push(events[key]);424      } else if(Array.isArray(events[key])) {425        output.push(...events[key]);426      } else {427        output.push(events[key]);428      }429    }430    output.sort((a, b) => a.logIndex - b.logIndex);431    return output.map(({address, event, returnValues}) => {432      const args: { [key: string]: string } = {};433      for(const key of Object.keys(returnValues)) {434        if(!key.match(/^[0-9]+$/)) {435          args[key] = returnValues[key];436        }437      }438      return {439        address,440        event,441        args,442      };443    });444  }445446  async calculateFee(address: ICrossAccountId, code: () => Promise<any>): Promise<bigint> {447    const wrappedCode = async () => {448      await code();449      // In dev mode, the transaction might not finish processing in time450      await this.helper.wait.newBlocks(1);451    };452    return await this.helper.arrange.calculcateFee(address, wrappedCode);453  }454}455456class EthAddressGroup extends EthGroupBase {457  extractCollectionId(address: string): number {458    if(!(address.length === 42 || address.length === 40)) throw new Error('address wrong format');459    return parseInt(address.slice(address.length - 8), 16);460  }461462  fromCollectionId(collectionId: number): string {463    if(collectionId >= 0xffffffff || collectionId < 0) throw new Error('collectionId overflow');464    return (Web3 as any).utils.toChecksumAddress(`0x17c4e6453cc49aaaaeaca894e6d9683e${collectionId.toString(16).padStart(8, '0')}`);465  }466467  extractTokenId(address: string): { collectionId: number, tokenId: number } {468    if(!address.startsWith('0x'))469      throw 'address not starts with "0x"';470    if(address.length > 42)471      throw 'address length is more than 20 bytes';472    return {473      collectionId: Number('0x' + address.substring(address.length - 16, address.length - 8)),474      tokenId: Number('0x' + address.substring(address.length - 8)),475    };476  }477478  fromTokenId(collectionId: number, tokenId: number): string {479    return this.helper.util.getTokenAddress({collectionId, tokenId});480  }481482  normalizeAddress(address: string): string {483    return '0x' + address.substring(address.length - 40);484  }485}486export class EthPropertyGroup extends EthGroupBase {487  property(key: string, value: string): EthProperty {488    return [489      key,490      '0x' + Buffer.from(value).toString('hex'),491    ];492  }493}494export type EthUniqueHelperConstructor = new (...args: any[]) => EthUniqueHelper;495496export class EthCrossAccountGroup extends EthGroupBase {497  createAccount(): CrossAddress {498    return this.fromAddress(this.helper.eth.createAccount());499  }500501  async createAccountWithBalance(donor: IKeyringPair, amount = 100n) {502    return this.fromAddress(await this.helper.eth.createAccountWithBalance(donor, amount));503  }504505  fromAddress(address: TEthereumAccount): CrossAddress {506    return {507      eth: address,508      sub: '0',509    };510  }511512  fromAddr(address: TEthereumAccount): [string, string] {513    return [514      address,515      '0',516    ];517  }518519  fromKeyringPair(keyring: IKeyringPair): CrossAddress {520    return {521      eth: '0x0000000000000000000000000000000000000000',522      sub: keyring.addressRaw,523    };524  }525}526527export class FeeGas {528  fee: number | bigint = 0n;529530  gas: number | bigint = 0n;531532  public static async build(helper: EthUniqueHelper, fee: bigint): Promise<FeeGas> {533    const instance = new FeeGas();534    instance.fee = instance.convertToTokens(fee);535    instance.gas = await instance.convertToGas(fee, helper);536    return instance;537  }538539  private async convertToGas(fee: bigint, helper: EthUniqueHelper): Promise<bigint> {540    const gasPrice = BigInt(await helper.getWeb3().eth.getGasPrice());541    return fee / gasPrice;542  }543544  private convertToTokens(value: bigint, nominal = 1_000_000_000_000_000_000n): number {545    return Number((value * 1000n) / nominal) / 1000;546  }547}548549class EthArrangeGroup extends ArrangeGroup {550  declare helper: EthUniqueHelper;551552  constructor(helper: EthUniqueHelper) {553    super(helper);554    this.helper = helper;555  }556557  async calculcateFeeGas(payer: ICrossAccountId, promise: () => Promise<any>): Promise<FeeGas> {558    const fee = await this.calculcateFee(payer, promise);559    return await FeeGas.build(this.helper, fee);560  }561}562export class EthUniqueHelper extends DevUniqueHelper {563  web3: Web3.default | null = null;564  web3Provider: WebsocketProvider | null = null;565566  eth: EthGroup;567  ethAddress: EthAddressGroup;568  ethCrossAccount: EthCrossAccountGroup;569  ethNativeContract: NativeContractGroup;570  ethContract: ContractGroup;571  ethProperty: EthPropertyGroup;572  declare arrange: EthArrangeGroup;573  constructor(logger: { log: (msg: any, level: any) => void, level: any }, options: { [key: string]: any } = {}) {574    options.helperBase = options.helperBase ?? EthUniqueHelper;575576    super(logger, options);577    this.eth = new EthGroup(this);578    this.ethAddress = new EthAddressGroup(this);579    this.ethCrossAccount = new EthCrossAccountGroup(this);580    this.ethNativeContract = new NativeContractGroup(this);581    this.ethContract = new ContractGroup(this);582    this.ethProperty = new EthPropertyGroup(this);583    this.arrange = new EthArrangeGroup(this);584    super.arrange = this.arrange;585  }586587  getWeb3(): Web3.default {588    if(this.web3 === null) throw Error('Web3 not connected');589    return this.web3;590  }591592  connectWeb3(wsEndpoint: string) {593    if(this.web3 !== null) return;594    this.web3Provider = new (Web3 as any).providers.WebsocketProvider(wsEndpoint);595    this.web3 = new (Web3 as any)(this.web3Provider);596  }597598  override async disconnect() {599    if(this.web3 === null) return;600    this.web3Provider?.connection.close();601602    await super.disconnect();603  }604605  override clearApi() {606    super.clearApi();607    this.web3 = null;608  }609610  override clone(helperCls: EthUniqueHelperConstructor, options?: { [key: string]: any; }): EthUniqueHelper {611    const newHelper = super.clone(helperCls, options) as EthUniqueHelper;612    newHelper.web3 = this.web3;613    newHelper.web3Provider = this.web3Provider;614615    return newHelper;616  }617}