Parts of this article were originally written for The Starknet Book. Check it out for more content on Starknet development.
The good news
If you've been following Starknet's progress, you're probably aware that there has been a lot of change recently. Cairo 1 was released, and that means frontend tools need updating to keep up.
If you have experience building dapps but are new to Starknet, the good news is that you can apply plenty of that knowledge here! If you're familiar with Starknet, the good news (in my opinion) is that the changes are moving in the right direction.
Getting started
The basic tools you can use to get started are Starknet.js and get-starknet. Starknet.js is an essential low level lib, equivalent to ethers.js, whereas get-starknet handles connecting to wallets. You should definitely read Starknet.js' docs for a more in-depth understanding.
With these tools, there are basically 3 main concepts to know on the frontend:
Account
We can generally think of the account as the "end user" of a dapp, and some user interaction will be involved to gain access to it.
Think of a dapp where the user connects their browser extension wallet (such as ArgentX or Braavos) - if the user accepts the connection, that gives us access to the account and signer, which can sign transactions and messages.
Unlike Ethereum, where user accounts are Externally Owned Accounts, Starknet accounts are contracts. This might not impact your dapp's frontend, but you should definitely be aware of this difference.
async function connectWallet() {
const starknet = await connect();
console.log(starknet.account);
starknet.account.signMessage(...)
}
The snippet above uses the connect
function provided by get-starknet
to establish a connection to the user wallet.
Provider
This one is pretty simple. The provider allows you to interact with the Starknet network. You can think of it as a "read" connection to the blockchain, with methods such as getBlock
or getChainId
.
Just like in Ethereum, you can use a default provider, or use services like Infura or Alchemy, both of which support Starknet.
export const provider = new Provider({
sequencer: {
network: "goerli-alpha",
},
/* rpc: {
nodeUrl: INFURA_ENDPOINT
} */
});
const block = await provider.getBlock("latest"); // <- Get latest block
console.log(block.block_number);
Contracts
Of course, your frontend will likely be interacting with deployed contracts. For each contract, there should be a counterpart on the frontend. To create these instances, you will need the contract's address and ABI, and either a provider or signer.
const contract = new Contract(
abi_erc20,
contractAddress,
starknet.account
);
contract.balanceOf(starknet.account.address)
If you create a contract instance with a provider, you'll be limited to calling read functions on the contract - only with a signer can you change the state of the blockchain.
Units
If you have previous experience with web3, you know dealing with units can be tricky, and Starknet is no exception. Once again, the docs are very useful here, especially this section on data transformation.
Very often you will need to convert Cairo structs (such as Uint256) that are returned from contracts into numbers:
// Uint256 shape:
// {
// type: 'struct',
// low: Uint256.low,
// high: Uint256.high
//
// }
const balance = await contract.balanceOf(address); // <- uint256
const asBN = uint256.uint256ToBN(uint256); // <- uint256 into BN
const asString = asBN.toString() //<- BN into string
And vice versa:
const amount = 1;
const amountFormatted = {
type: "struct",
...uint256.bnToUint256(amount),
};
If we put it all together, calling a transfer
function on a contract looks like this:
const tx = await contract.transfer(recipientAddress, amountFormatted);
//or: const tx = await contract.invoke("transfer", [recipientAddress, amountFormatted]);
console.log(`Tx hash: ${tx.transaction_hash}`);
There are other helpful utils, besides bnToUint256
and uint256ToBN
, provided by Starknet.js.
We now have a solid foundation to build a Starknet dapp! If you have a background in regular Ethereum apps, you'll notice how similar the experience is, but you're probably also wondering if there are more high-level tools out there that we can use. Well, there are!
Starknet React
Starknet React is an open-source collection of React providers and hooks for Starknet, inspired by wagmi. If you've used wagmi before, you should have a pretty good idea of what it looks like, as it aims to have a very similar API.
To explore an example project showcasing a dapp built with Starknet React, check out the starknet-demo-dapp repo.
To use Starknet React, start by installing the necessary dependencies:
yarn add @starknet-react/core starknet get-starknet
Starknet.js is an SDK for interacting with Starknet, whereas get-starknet is a package for handling wallet connections.
Then, wrap your app in a StarknetConfig
component. This allows for some configuration and provides a React Context for the underlying app to be able to use the shared data and hooks. StarknetConfig
receives a connectors
prop that defines which wallet connection options will be available to the user.
const connectors = [
new InjectedConnector({ options: { id: "braavos" } }),
new InjectedConnector({ options: { id: "argentX" } }),
];
return (
<StarknetConfig
connectors={connectors}
autoConnect
>
<App />
</StarknetConfig>
)
Connection and account
You can now use a hook to access the connectors you defined in the config, allowing the user to connect their wallet:
export default function Connect() {
const { connect, connectors, disconnect } = useConnectors();
return (
<div>
{connectors.map((connector) => (
<button
key={connector.id()}
onClick={() => connect(connector)}
disabled={!connector.available()}
>
Connect with {connector.id()}
</button>
))}
</div>
);
}
Note that there is also a disconnect
function provided, in order to end the connection. Once the user has connected their wallet, you have access to the connected account through the useAccount
hook. This also provides the current state of connection:
const { address, isConnected, isReconnecting, account } = useAccount();
return (
<div>
{isConnected ? (
<p>Hello, {address}</p>
) : (
<Connect />
)}
</div>
);
Note that state values such as isConnected
and isReconnecting
are updated automatically, making it easier to update the UI conditionally. This is a very useful and common pattern when dealing with asynchronous processes, as it means you don't have to manually keep track of that state locally in your components.
Once the user is connected, signing messages may be done through the account
value returned from the useAccount
hook, or more simply through the useSignTypedData
hook.
const { data, signTypedData } = useSignTypedData(typedMessage)
return (
<>
<p>
<button onClick={signTypedData}>Sign</button>
</p>
{data && <p>Signed: {JSON.stringify(data)}</p>}
</>
)
You can sign an array of BigNumberish
, or an object. In the event of signing an object, the data must be correctly typed, in accordance with EIP712. You can find a more in depth explanation here.
Network
Starknet React also provides hooks for interacting with the provider. For instance, useBlock
fetches the latest block:
const { data, isError, isFetching } = useBlock({
refetchInterval: 10_000,
blockIdentifier: "latest",
});
if (isError) {
return (
<p>Something went wrong</p>
)
}
return (
<p>Current block: {isFetching ? "Loading..." : data?.block_number}<p>
)
The refetchInterval
specifies how often the hook will attempt to refetch the data. Under the hood, Starknet React uses react-query for state and query management. Besides useBlock
, there are other hooks that can be configured to periodically update, such as useContractRead
and useWaitForTransaction
.
With the useStarknet
hook, it's also possible to directly access the ProviderInterface
:
const { library } = useStarknet();
// library.getClassByHash(...)
// library.getTransaction(...)
Interacting with contracts
Read functions
Just like with wagmi, there is a useContractRead
hook provided specifically for calling read functions on contracts. Note that this may be used even with without a connected user, as read functions don't require a signer.
const { data: balance, isLoading, isError, isSuccess } = useContractRead({
abi: abi_erc20,
address: CONTRACT_ADDRESS,
functionName: "allowance",
args: [owner, spender],
// watch: true <- refresh at every block
});
For working with ERC20s, there is also a convenience hook - useBalance
. This hook doesn't require passing in an ABI, and will return a correctly formatted balance value.
const { data, isLoading } = useBalance({
address,
token: CONTRACT_ADDRESS, // <- defaults to the ETH token
// watch: true <- refresh at every block
});
return (
<p>Balance: {data?.formatted} {data?.symbol}</p>
)
Write functions
For write functions, the useContractWrite
hook is slightly different to wagmi. Due to Starknet's architecture, accounts can natively support multicall transactions. In practice, this means an improved user experience when executing multiple transactions, as you won't have to individually approve each transaction. Starknet React makes the most of this feature through the useContractWrite
hook. It can be used in the following manner:
const calls = useMemo(() => {
// compile the calldata to send
const calldata = stark.compileCalldata({
argName: argValue,
});
// return a single object for single transaction,
// or an array of objects for multicall**
return {
contractAddress: CONTRACT_ADDRESS,
entrypoint: functionName,
calldata,
};
}, [argValue]);
// Returns a function to trigger the transaction
// and state of tx after being sent
const { write, isLoading, data } = useContractWrite({
calls,
});
function execute() {
// trigger the transaction
write();
}
return (
<button type="button" onClick={execute}>
Make a transaction
</button>
)
In the example above, we first compile the calldata to be executed using Starknet.js's compileCalldata
util. We pass the contract address, entrypoint, and calldata to the useContractWrite
hook, which returns a write
function we can use to actually trigger the transaction. The hook also returns the hash and state of the contract call.
Single instance of a contract
Using useContractRead
and useContractWrite
might not be a good fit for your use case - you might want to work with a single instance of the contract, instead of continually specifying its address and ABI in individual hooks. This is also possible throught the useContract
hook:
const { contract } = useContract({
address: CONTRACT_ADDRESS,
abi: abi_erc20,
});
// Call functions directly on contract
// contract.transfer(...);
// contract.balanceOf(...);
Transactions
Once you have a transaction hash, you may track it's state through the useTransaction
hook. This keeps a cache of all transactions, reducing duplicated network requests.
const { data, isLoading, error } = useTransaction({ hash: txHash });
return (
<pre>
{JSON.stringify(data?.calldata)}
</pre>
)
Wrapping up
You can find a working example with any of these hooks implemented in this repo.
Expect more changes in the next few months, as the Starknet ecosystem continues to evolve!
Happy hacking 👋
Top comments (0)