Skip to content

Instantly share code, notes, and snippets.

@ellemedit
Last active August 5, 2024 08:42
Show Gist options
  • Save ellemedit/d26ecf812b6d1851ecebbe3a7c388d2f to your computer and use it in GitHub Desktop.
Save ellemedit/d26ecf812b6d1851ecebbe3a7c388d2f to your computer and use it in GitHub Desktop.
[viem] type safe and maintainable contract call patterns with ABI

React 컴포넌트에서 컨트랙트를 호출하는 다음과 같은 코드가 있다고 가정해봅시다.

function buildCreateTokenTransaction({
  name,
  symbol,
  caipChainID,
  creatorAddress,
}: {
  caipChainID: CAIPChainID;
  name: string;
  symbol: string;
  creatorAddress: ChecksumAddress;
}) {
  const creationFee = getTokenCreationFee(caipChainID);
  return {
    to: getBondContractAddress(caipChainID),
    value: creationFee,
    data: encodeCreateTokenCallData(creatorAddress, {
      name,
      symbol,
    }),
  };
}

function Component() {
  const sendTransaction = useSendTransaction(); // wagmi
  return (
    <form action={async (formData) => {
      const { caipChainId, tokenContractAddress, amount, ... } = parseFormData(formData);
      const transaction = buildMintTokenTransaction({
        caipChainID: caipChainId,
        tokenContractAddress,
        seiAmount: amount,
        mintMinimumAmount: minAmountOut,
        receiverAddress: toAddress,
      });
      sendTransaction(transaction)
    }}>
      ...
    </form>
  )
}

이런 코드 패턴에서 buildCreateTokenTransaction는 컨트랙트의 ABI를 보장하지 못합니다. 해당 함수에서 정의한 타입 인터페이스는 그냥 TypeScript 레벨에서 정의한 것 뿐이고, ABI로부터 파생된 정보가 아니기 때문입니다.

대신 viem의 getContract 함수를 사용하면 ABI와 연동되는 타입안전한 컨트렉트 호출이 가능합니다. 다음과 같이 작업할 수 있습니다:

function useContract() {
  const client = useWalletClient();
  return getContract({
    abi: [...ABI],
    address: `0x....`,
    client,
  });
}

function Component() {
  const contract = getContract();
  return (
    <form action={(formData) => contract.write.createToken(parseFormData(formData))}>
      {...}
    </form>
  )
}

쉽습니다. 다만 위 패턴에서는 항상 wallet 클라이언트 주입을 가정합니다. wallet 클라이언트는 쓰기 컨트렉트에서만 필요합니다. 읽기 작업만 수행하는데 지갑 연결이 필요한 것은 UX를 해칠 수 있습니다. 따라서 우리는 문제를 다음과 같이 분리해볼 수 있습니다.

function useReadContract() {
  const client = usePublicClient();
  return getContract({
    abi: [...ABI],
    address: `0x....`,
    client,
  }).read;
}

function useWriteContract() {
  const client = useWalletClient()
  return () => {
    if (client == null) {
      return null;
    }
    return getContract({
      abi: [...ABI],
      address: `0x....`,
      client: walletClient,
    }).write;
  }
}

function Component() {
  const readContract = useReadContract()
  const resolveWriteContract = useWriteContractResolver()
  const account = useAccount()
  
  return (
    <form action={formData => {
      const writeContract = resolveWriteContract();
      if (writeContract == null) {
        alert('connect wallet')
        return;
      }
      writeContract.createToken(parseFormData(formData))
    }}>
      {...}
      <Suspense fallback={<span>estimating ...</span>}>
        {readContract.creationFee(...).then(([fee]) => <span>{fee} coin</span>)}
      </Suspense>
    </form>
  )
}

위와 같은 패턴은 지갑 사용자의 로그인 유무를 신경쓰지 않고 각각 필요에 맞춰 옵티멀한 코드를 작성할 수 있게 도와줍니다.

정리해보자면 다음과 같습니다. encodeCallData 등 raw level API를 사용하여 ABI를 만족시키는 대신, getContract 함수를 사용하여 더 적은 코드로 ABI와 민감하게 반응하며 지갑상태와 무관하게 UI를 작성하는 코드를 작성할 수 있습니다.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment