FuelLabs / fuels-ts

Fuel Network Typescript SDK
https://docs.fuel.network/docs/fuels-ts/
Apache License 2.0
44.26k stars 1.34k forks source link

Prettify typegen API #2686

Closed nedsalk closed 1 month ago

nedsalk commented 2 months ago

The end goal is to have typegen only output types and integrate those types with the real classes via generic type parameters. typegen currently outputs code which casts programs as the types it generates. For example, for a simple counter contract which looks like this:

abi Counter {
    #[storage(read)]
    fn get_count() -> u64;

    #[storage(write, read)]
    fn increment_count(amount: u64) -> u64;
}

The generated outputs are CounterAbi.d.ts and CounterAbi__factory.ts, respectively:

// CounterAbi.d.ts
interface CounterAbiInterface extends Interface {
  functions: {
    get_count: FunctionFragment;
    increment_count: FunctionFragment;
  };
}

export class CounterAbi extends Contract {
  interface: CounterAbiInterface;
  functions: {
    get_count: InvokeFunction<[], BN>;
    increment_count: InvokeFunction<[amount: BigNumberish], BN>;
  };
}
// CounterAbi__factory.ts
export const CounterAbi__factory = {
  abi: _abi,

  storageSlots: _storageSlots,

  createInterface(): CounterAbiInterface {
    return new Interface(_abi) as unknown as CounterAbiInterface
  },

  connect(
    id: string | AbstractAddress,
    accountOrProvider: Account | Provider
  ): CounterAbi {
    return new Contract(id, _abi, accountOrProvider) as unknown as CounterAbi
  },

  async deployContract(
    bytecode: BytesLike,
    wallet: Account,
    options: DeployContractOptions = {}
  ): Promise<CounterAbi> {
    const factory = new ContractFactory(bytecode, _abi, wallet);

    const contract = await factory.deployContract({
      storageSlots: _storageSlots,
      ...options,
    });

    return contract as unknown as CounterAbi;
  }
}

The idea is for CounterAbi.d.ts to contain an appropriate type representation of the ABI which would be passed to Interface<TAbi> and Contract<TAbi> classes, removing the need for the as unknown as CounterAbi casts. Below is an idea of the updated CounterAbi.d.ts and CounterAbi__factory.ts contents, respectively:

// CounterAbi.d.ts
export interface CounterAbi {
  functions: {
    get_count: {
      inputs: [];
      output: BN;
    };
    increment_count: {
      inputs: [amount: BigNumberish];
      output: BN;
    };
  };
  configurableConstants: { };
}
// CounterAbi__factory.ts
export const CounterAbi__factory = {
  abi: _abi,

  storageSlots: _storageSlots,

  createInterface() {
    return new Interface<CounterAbi>(_abi);
  },

  connect(
    id: string | AbstractAddress,
    accountOrProvider: Account | Provider
  ) {
    return new Contract<CounterAbi>(id, _abi, accountOrProvider)
  },

  async deployContract(
    bytecode: BytesLike,
    wallet: Account,
    options: DeployContractOptions = {}
  ) {
    const factory = new ContractFactory<CounterAbi>(bytecode, _abi, wallet);

    const contract = await factory.deployContract({
      storageSlots: _storageSlots,
      ...options,
    });

    return contract;
  }
}

This same interface can be generated for predicate and script abis:

export interface PredicateAbi {
  functions: {
    main: {
      inputs: [myPredicateInput: boolean];
      output: boolean;
    };
  configurableConstants: { };
}

export interface ScriptAbi {
  functions: {
    main: {
      inputs: [myScriptInput: BN];
      output: boolean;
    };
  configurableConstants: { };
}

This approach allows clean integration of logs (#2330) into the same interface as well later down the line, although that issue is not blocked by this one:

export interface SomeContractAbi {
  functions: { ... };
  configurableConstants: {...};
  logs: {...};
}
arboleya commented 1 month ago

@nedsalk What do you think about separating deployment stuff from the rest?

I've been questioning our weird suffixes for these files; maybe they could go.

Related:

import { Counter, deployCounter } from './typegend';

Counter.ts

export interface Counter {
  functions: {
    get_count: {
      inputs: [];
      output: BN;
    };
    increment_count: {
      inputs: [amount: BigNumberish];
      output: BN;
    };
  };
  configurableConstants: { };
}

export const counterAbi = "...";

export const counterStorageSlots = [];

export function createInterface() {
  return new Interface<Counter>(counterAbi);
}

export function create(
  id: string | AbstractAddress,
  accountOrProvider: Account | Provider
) {
  return new Contract<Counter>(id, counterAbi, accountOrProvider);
}

deployCounter.ts

// deployCounter.ts
import { Account, DeployContractOptions, DeployContractResult, ContractFactory } from 'fuels';
import { Counter, counterAbi, counterStorageSlots } from './Counter'

export const counterBytecode = '0x1af03..';

export async function deployCounter(
  wallet: Account,
  options: DeployContractOptions = {}
): Promise<DeployContractResult<Counter>> {
  const factory = new ContractFactory(counterBytecode, counterAbi, wallet);

  return factory.deployContract<Counter>({
    storageSlots: counterStorageSlots,
    ...options,
  });
}
nedsalk commented 1 month ago

I wholeheartedly agree with the direction.

I think we could give the users the full factory and have them do whatever they want with it:

// counter-factory.ts
import { Account, Provider, ContractFactory } from 'fuels';
import { Counter, abi } from './Counter'

export const counterBytecode = '0x1af03..';

// as a class
export class CounterFactory extends ContractFactory {
  constructor(accountOrProvider: Account | Provider | null) {
    super(counterBytecode, abi, accountOrProvider);
  }
}

// or as a factory function, which is nicer for importing,
// but which could give import problems to those who use multiple factories.
// although who uses multiple factories in their code anyways ... except us in tests :sweat_smile: 
export function factory(accountOrProvider: Account | Provider | null = null) {
  return new ContractFactory<Counter>(counterBytecode, abi, accountOrProvider);
}

This way we wouldn't have any logic in typegen outputs and it'd just be a type wrapper/implementer of the real classes.

arboleya commented 1 month ago

[!IMPORTANT] All pseudo code.

We can use prefixes/sufixes to avoid naming collisions.

// Counter.ts
export interface Counter {
  functions: {
    get_count: {
      inputs: [];
      output: BN;
    };
    increment_count: {
      inputs: [amount: BigNumberish];
      output: BN;
    };
  };
  configurableConstants: { };
}

export const counterAbi = "...";

export const counterStorageSlots = [];

export function createCounterInterface() {
  return new Interface<Counter>(counterAbi);
}

export function createCounter(
  id: string | AbstractAddress,
  accountOrProvider: Account | Provider
) {
  return new Contract<Counter>(id, counterAbi, accountOrProvider);
}
// CounterFactory.ts
import { Counter, counterAbi, counterStorageSlots } from './Counter'

export const counterBytecode = '0x1af03..';

export class CounterFactory extends ContractFactory {
  constructor(accountOrProvider: Account | Provider | null) {
    super(counterBytecode, counterAbi, accountOrProvider);
  }
}

export async function factoryCounter(wallet: Account): DeployContractResult {
  return new CounterFactory(wallet);
}

export async function deployCounter(
  wallet: Account,
  options: DeployContractOptions = {}
): Promise<DeployContractResult> {
  return factoryCounter(wallet).deployContract({
    storageSlots: counterStorageSlots,
    ...options,
  });
}
arboleya commented 1 month ago

It seems users should mainly be concerned with:

  1. createCounter(id, provider)
  2. deployCounter(wallet)
arboleya commented 1 month ago

This is how it should be used:

const deploy = await deployCounter(wallet);
const { contract } = await deploy.waitForResult();

Full snippet (with hypothetical unified API):

import type { TxParams } from 'fuels';
import { LOCAL_NETWORK_URL, fuels, bn } from 'fuels';

import { WALLET_PVT_KEY } from './env';
import { deployCounter } from './typegend';

const client = await fuels(LOCAL_NETWORK_URL);
const wallet = client.wallet(WALLET_PVT_KEY);

const deploy = await deployCounter(wallet);
const { contract } = await deploy.waitForResult();

const txParams: TxParams = {
  gasLimit: bn(69242),
  maxFee: bn(69242),
  tip: bn(100),
  maturity: 1,
  witnessLimit: bn(5000),
};

const { waitForResult } = await contract.functions
  .increment_count(15) //
  .txParams(txParams)
  .call();

const {
  value,
  transactionResult: { isStatusSuccess },
} = await waitForResult();

console.log({ value, isStatusSuccess });

[!NOTE] @danielbate This is also a port of the Transaction Parameters snippet.