Introducing Surf: Type-Safe TypeScript Interfaces & React Hooks for Aptos

Thala Labs
9 min readJul 12, 2023

--

TL;DR

Surf is type-safe TypeScript interfaces and React Hooks for Aptos. In this article, we discuss some of the limitations of the Aptos SDK and how Surf addresses these issues by leveraging instant static type inference without a code generation process. We also compare several design choices, and share a implementation deep-dive of Surf.

Motivation

With Aptos SDK, developers typically use view and getAccountResource to read contract data, and submitTransaction to write contract data. However, due to the under-typed interfaces, developers can easily make mistakes. Let's take a look at how errors can occur when using view.

view(payload: ViewRequest, ledger_version?: string): Promise<MoveValue[]>;

declare type ViewRequest = {
// typo prone, can be eliminated using abi
function: string;
type_arguments: Array<string>;
// we should be able to do better than `any` using type info from abi
arguments: Array<any>;
};

// this is not so different from `any`
declare type MoveValue = (number | U64$1 | U128 | U256 | boolean | Address | Array<MoveValue> | HexEncodedBytes | MoveStructValue | string);

Firstly, typos. It’s possible to pass 0x1::coin::belance as a view request function, even though this could be prevented by inferring from the ABI.

Secondly, it can be very difficult to get arguments right since they take any, particularly for complex types such as vector. After numerous errors and attempts, we've discovered that it only accepts a hex string for vector<u8>, number[] for vector<u16>, and string[] for vector<u64>.

// For vector<u8> as input argument.
await aptosClient.view({
function: "0x123::vector::vector_u8",
arguments: [
HexString.fromUint8Array(new Uint8Array([1,2,3]))
],
type_arguments: [],
});

// For vector<u16> as input argument.
await aptosClient.view({
function: "0x123::vector::vector_u16",
arguments: [[1,2,3]],
type_arguments: [],
});

// For vector<u64> as input argument.
await aptosClient.view({
function: "0x123::vector::vector_u64",
arguments: [["1","2","3"]],
type_arguments: [],
});

Last but not least, return value can be easily exploited, as shown in the below example.

import { AptosClient } from "aptos";
const aptosClient = new AptosClient("<https://fullnode.mainnet.aptoslabs.com/v1>");

const result = await aptosClient.view({
function: "0x1::coin::balance",
arguments: ["0x123456"],
type_arguments: ["0x1:aptos_coin:AptosCoin"],
});

// The result[0] is actually a string.
// But nothing will prevent you from computing as a number.
result[0] + 1

// Nothing will prevent you from accessing an index that is out of range
console.log(result[1]);

As a work around, we can declare and confirm the return type ourselves. But, as shown in the getAccountResource example below, this is difficult and could have been improved by directly inferring from ABI.

const resource = aptosClient.getAccountResource(
accountAddress,
"0x1::coin::CoinInfo<0x1::aptos_coin::AptosCoin>"
) as {
type: string,
data: {
decimals: number,
name: string,
symbol: string,
supply: { ... }
}
};

Solution

We created Surf to fix these problems. By statically inferring from ABI, Surf can give you information on what input and output to expect as you write your code.

It can also correct your inputs right away.

And you know what to do with the return value with confidence.

import { createClient } from "@thalalabs/surf";
const client = createClient({
nodeUrl: '<https://fullnode.mainnet.aptoslabs.com/v1>',
});

const coinClient = client.useABI(COIN_ABI);
const result = await coinClient.view.balance({
arguments: ["0x123456"],
type_arguments: ["0x1::aptos_coin::AptosCoin"],
});

const balance = result[0];

// If you perform an incorrect operation on the `balance`, you will get a compilation error:
// ts(2365): Operator '+' cannot be applied to types 'bigint' and '1'.
console.log(balance + 1);

// Access index out of range will get a compilation error:
// ts(2493): Tuple type 'readonly [bigint]' of length '1' has no element at index '1'.
console.log(result[1]);

Besides .view, you can also do .entry and .resource in a type-safe manner.

// transfer 100 AptosCoin to 0x123456
const { hash } = await coinClient.entry.transfer({
arguments: ['0x123456', 10000000000],
type_arguments: ['0x1::aptos_coin::AptosCoin'],
account,
});

// get 0x1::coin::CoinStore<0x1::aptos_coin::AptosCoin>
const { data } = await coinClient.resource.CoinStore({
type_arguments: ['0x1::aptos_coin::AptosCoin'],
account: '0x1',
});

// use the account resource object with type safety.
console.log(data.coin.value);

Design choices

We looked at different ways to make Surf safe and simple. Here are the ways we thought about:

  • Code Generation from contract source code: Hippo’s move-to-ts does this, relies on Move compiler to turn Move source code into TypeScript utilities. However, the approach is not always useful as source code is not always available.
  • Code Generation from ABI: This method is used by libraries like TypeChain for Ethereum. It is simple to use, and ABIs are easily accessible from Aptos Explorer. However, it requires generating new code every time new contracts are added, which can disrupt the typical frontend development process. Furthermore, the additional generated code would increase the bundle size.
  • Static type inference based on ABI: This approach, used by libraries like Viem for Ethereum, achieves type safety by instantly inferring types from JSON ABIs, eliminating the need for code generation. Developers don’t have to do a separate code generation step during their workflow. Also, since type checks happen only during compile time, the runtime cost is reduced.

At first, we made Surf using code generation. We thought the Viem approach was cool, but it was harder to do. The ABIType library (which Viem uses) still says that no other library does what it does (which inferences TypeScript types from ABIs and EIP-712 Typed Data).

As we worked on Surf, we learned how to figure out a type from JSON. So we ended up using the Viem approach.

Implementation walkthrough

We organized Surf code into different layers, each with its own purpose. This helps us create a flexible and complete solution for working with Aptos blockchain smart contracts. In the future, we might divide some of these layers into separate libraries.

Type Inference Layer

The type inference layer is available at src/types. It helps with type checking and linting in IDEs, and has utility generic types for other layers to use. For example:

  • To get all function names, use FunctionName<COIN_ABI>. The result will be: 'balance' | 'decimals' | 'name' |'transfer' | ....
  • To get the return types of a function in Move, use ExtractRawReturnType<COIN_ABI, 'balance'>. The result will be [ u64 ].
  • To convert the return type from Move to TypeScript, use ConvertReturns<[ u64 ]>. The result will be: [ AnyNumber ].
  • You can combine these utilities, like this: ConvertReturns<ExtractRawReturnType<COIN_ABI, 'balance'>>.

Remember that all of this code is purely for type checking and linting, and will be stripped out after compilation.

Core Layer

The code for this layer is available at src/core and is built on top of the Type Inference Layer and the official Aptos library. In this layer, we provide TypeScript interfaces for working with smart contracts, as well as encoding/decoding logic to simplify usage of the library and remove low-level complexity.

Let’s use createEntryPayload as an example.

// Powered by the Type Inference Layer, this function has type safety.
// And it also take care the BCS encoding for input args.
const payload = createEntryPayload(COIN_ABI, {
function: 'transfer',
arguments: ['0x1', 1],
type_arguments: ['0x1::aptos_coin::AptosCoin'],
});

const result = await client.submitTransaction(payload, { account });

Note that the types could be inferenced thanks to EntryFunctionName, ExtractParamsTypeOmitSigner and ExtractGenericParamsType utility types from the Type Inference Layer.

function createEntryPayload<
TABI extends ABIRoot,
TFuncName extends EntryFunctionName<TABI>
>(
abi: TABI,
payload: {
// inference the function name
function: TFuncName,
// inference the arguments based on the ABI and the function name
arguments: ExtractParamsTypeOmitSigner<TABI, TFuncName>,
// inference the type arguments based on the ABI and the function name
type_arguments: ExtractGenericParamsType<TABI, TFuncName>
}
): EntryPayload

React Hooks Layer

The code for this layer is located at src/hooks. These React hooks use the Core layer and the Aptos wallet adapter library to ensure type safety and enable developers to interact with Aptos smart contracts in React.

const {
isLoading: submitIsLoading,
submitTransaction,
data,
error
} = useSubmitTransaction();

// Submit transaction on click
const onClick = () => {
// The type-safe interface from the Core Layer
const payload = createEntryPayload(COIN_ABI, {
function: "transfer",
type_arguments: ["0x1::aptos_coin::AptosCoin"],
arguments: ["0x1", BigInt(1)]
});

await submitTransaction(payload, { nodeUrl: "<https://fullnode.testnet.aptoslabs.com/v1>" });
}

Proxy Wrapper Layer

We added a proxy wrapper layer to offer alternative interfaces that resemble traditional SDKs. It uses the JavaScript Proxy feature to route function calls to the Core layer’s appropriate methods. The ABI is not being parsed at runtime and there is only one real implementation shared by all of those functions. We use type inference tricks to make IDE think there are many separate functions behind, but actually there aren’t.

Here’s an example:

const coinClient = client.useABI(COIN_ABI);

// Call an entry function
const { hash } = await coinClient.entry.transfer({
arguments: ['0x123456', 100],
type_arguments: ['0x1::aptos_coin::AptosCoin'],
account,
});

// Call a view function
const [ balance ] = await coinClient.view.balance({
arguments: ["0x123456"],
type_arguments: ["0x1::aptos_coin::AptosCoin"],
});

// Get account resource
const { data } = await coinClient.resource.CoinInfo({
type_arguments: ['0x1::aptos_coin::AptosCoin'],
account: '0x1',
});
console.log(data.decimals)
console.log(data.name)
console.log(data.symbol)

Conclusion

Thala is committed to continue contributing to the Aptos ecosystem and ensuring an unparalleled developer experience for its growing community of developers. With static type inference and comprehensive APIs, Surf ensures end-to-end type safety throughout your interaction with smart contracts.

We’re always open to feedback and suggestions. For bug-related questions or feature requests, please reach out on Thala’s social channels.

For more details, please visit https://github.com/ThalaLabs/surf.

Thala is a decentralized finance protocol powered by the Move language, enabling seamless borrowing of a decentralized, over-collateralized stablecoin in Move Dollar and capital-efficient liquidity provisioning via a rebalancing AMM on the Aptos blockchain.

Website: http://thala.fi/

Discord: https://discord.gg/thala

Twitter: http://twitter.com/thalalabs

Documentation: https://docs.thala.fi

Disclaimer

This article by Thala Labs and/or its affiliates (“we”, “us” and “our”) is for information purposes only. We do not provide tax, legal, insurance or investment advice, and nothing in this article should be construed as an offer to sell, a solicitation of an offer to buy, sell or issue or subscribe for, or a recommendation for any security, investment, cryptocurrency, token or other services, product or commodity by us or any third party. You alone are solely responsible for determining whether any purchase, sale, investment, security or strategy, or any other product or service, is appropriate or suitable for you based on your personal objectives and personal and financial situation and for evaluating the merits and risks associated with the use of the information in this article before making any decisions based on such information or other content. You should consult a lawyer and/or tax professional regarding your specific legal and/or tax situation. Past performance is no guarantee of future results. Therefore, you should not assume that the future performance of any specific investment, cryptocurrency, token, commodity or strategy will be profitable or equal to corresponding past performance levels. Inherent in any such transaction is the potential for loss. No recommendation or advice is being given as to whether any transaction is suitable for a particular person. By accessing this article, you acknowledge and agree to all of the foregoing and that you bear responsibility for your own research, due diligence and transaction decisions. You also agree that we, our affiliates and our respective directors, officers, employees, consultants, shareholders, members, representatives, advisors and agents will not be liable for any decision made or action taken by you and others based on this article, news, information, opinion, or any other material published, discussed or disseminated by us.

This article contains forward-looking statements or forward-looking information (referred to collectively as “forward-looking statements”). Forward-looking statements can be identified by words such as: “anticipate”, “intend”, “plan”, “goal”, “seek”, “believe”, “predict”, “project”, “estimate”, “expect”, “strategy”, “future”, “likely”, “may”, “should”, ”would”, “will”, and similar terms and phrases and the negatives of such expressions, including references to assumptions. Examples of forward-looking statements in this article include, among others, statements we make regarding our future plans, expectations and objectives.

Forward-looking statements are neither historical facts nor assurances of future performance. Instead, they are based only on our current beliefs, expectations and assumptions regarding the future of our business, future plans and strategies, projections, anticipated events and trends, the economy and other future conditions. Because forward-looking statements relate to the future, they are subject to inherent uncertainties, risks and changes in circumstances that are difficult to predict and many of which are outside of our control. Our actual results and financial condition may differ materially from those indicated in the forward-looking statements. Therefore, you should not rely on any of these forward-looking statements. Important factors that could cause our actual results and financial condition to differ materially from those indicated in the forward-looking statements include, among others, the following: reliance on blockchain technology and blockchain technology service providers; digital asset transactions being irrevocable and losses occurring from such transactions; our use and reliance on proprietary data and intellectual property in its business; potential misuses of digital assets and malicious actors in the digital asset industry; digital assets potentially being subject to hold periods; developments and changes in laws and regulations; and disruptions to our technology network including computer systems, software and cloud data, or other disruptions of our operating systems, structures or equipment. Readers are cautioned that the foregoing list is not exhaustive.

Any forward-looking statement made by us in this article is based only on information currently available to us and speaks only as of the date on which it is made. Except as required by applicable securities laws, we undertake no obligation to publicly update any forward-looking statement, whether written or oral, that may be made from time to time, whether as a result of new information, future developments or otherwise.

--

--

Thala Labs

Thala is a decentralized finance protocol on the Aptos blockchain.