Skip to content

Commit

Permalink
feat: ccip read (#427)
Browse files Browse the repository at this point in the history
* wip

* polish

* format

* exports

* chore: changeset

* fix: import

* tests: fix

* pr review

* pr review
  • Loading branch information
jxom committed Apr 30, 2023
1 parent 840d19d commit 41bc9e3
Show file tree
Hide file tree
Showing 21 changed files with 914 additions and 17 deletions.
5 changes: 5 additions & 0 deletions .changeset/loud-rockets-shout.md
@@ -0,0 +1,5 @@
---
"viem": patch
---

Added support for EIP-3668 CCIP Read.
71 changes: 71 additions & 0 deletions contracts/src/OffchainLookupExample.sol
@@ -0,0 +1,71 @@
// SPDX-License-Identifier: Unlicense
pragma solidity ^0.8.13;

contract OffchainLookupExample {
error OffchainLookup(
address sender,
string[] urls,
bytes callData,
bytes4 callbackFunction,
bytes extraData
);

string[] public urls;

constructor(string[] memory urls_) {
urls = urls_;
}

function getAddress(string calldata name) public view returns (address) {
revert OffchainLookup(
address(this),
urls,
abi.encode(name),
OffchainLookupExample.getAddressWithProof.selector,
abi.encode(name)
);
}

function getAddressWithProof(
bytes calldata result,
bytes calldata extraData
) external view returns (address) {
(address owner, bytes32 hash, bytes memory sig) = abi.decode(result, (address, bytes32, bytes));
address signer = recoverSigner(hash, sig);
require(signer == owner, "invalid signature");
return signer;
}

function recoverSigner(
bytes32 _ethSignedMessageHash,
bytes memory _signature
) public pure returns (address) {
(bytes32 r, bytes32 s, uint8 v) = splitSignature(_signature);

return ecrecover(_ethSignedMessageHash, v, r, s);
}

function splitSignature(
bytes memory sig
) public pure returns (bytes32 r, bytes32 s, uint8 v) {
require(sig.length == 65, "invalid signature length");

assembly {
/*
First 32 bytes stores the length of the signature
add(sig, 32) = pointer of sig + 32
effectively, skips first 32 bytes of signature
mload(p) loads next 32 bytes starting at the memory address p into memory
*/

// first 32 bytes, after the length prefix
r := mload(add(sig, 32))
// second 32 bytes
s := mload(add(sig, 64))
// final byte (first byte of the next 32 bytes)
v := byte(0, mload(add(sig, 96)))
}
}
}
39 changes: 39 additions & 0 deletions src/_test/ccip.ts
@@ -0,0 +1,39 @@
import {
decodeAbiParameters,
encodeAbiParameters,
parseAbiParameters,
} from '../utils/abi/index.js'
import { sign } from '../accounts/utils/sign.js'
import { signatureToHex } from '../accounts/utils/signatureToHex.js'
import { stringToHex } from '../utils/encoding/index.js'
import { keccak256 } from '../utils/hash/index.js'
import { accounts } from './constants.js'
import { createHttpServer } from './utils.js'
import type { Hex } from '../types/index.js'

export function createCcipServer() {
return createHttpServer(async (req, res) => {
res.writeHead(200, {
'Content-Type': 'application/json',
})
const signature = signatureToHex(
await sign({
hash: keccak256(stringToHex('jxom.viem')),
privateKey: accounts[0].privateKey,
}),
)

const data = req.url?.split('/')[2]! as Hex
const [name] = decodeAbiParameters(parseAbiParameters('string'), data)

res.end(
JSON.stringify({
data: encodeAbiParameters(parseAbiParameters('address,bytes32,bytes'), [
accounts[0].address,
keccak256(stringToHex(name)),
signature,
]),
}),
)
})
}
1 change: 1 addition & 0 deletions src/_test/index.ts
Expand Up @@ -23,6 +23,7 @@ export {
deployBAYC,
deployEnsAvatarTokenUri,
deployErc20InvalidTransferEvent,
deployOffchainLookupExample,
publicClient,
testClient,
walletClient,
Expand Down
13 changes: 13 additions & 0 deletions src/_test/utils.ts
Expand Up @@ -3,6 +3,7 @@ import type { Abi } from 'abitype'
import ensAvatarTokenUri from '../../contracts/out/EnsAvatarTokenUri.sol/EnsAvatarTokenUri.json'
import erc20InvalidTransferEvent from '../../contracts/out/ERC20InvalidTransferEvent.sol/ERC20InvalidTransferEvent.json'
import errorsExample from '../../contracts/out/ErrorsExample.sol/ErrorsExample.json'
import offchainLookupExample from '../../contracts/out/OffchainLookupExample.sol/OffchainLookupExample.json'
import type { DeployContractParameters } from '../actions/index.js'
import {
deployContract,
Expand Down Expand Up @@ -32,6 +33,7 @@ import {
ensAvatarTokenUriABI,
erc20InvalidTransferEventABI,
errorsExampleABI,
offchainLookupExampleABI,
} from './generated.js'

import type { RequestListener } from 'http'
Expand Down Expand Up @@ -220,6 +222,17 @@ export async function deployErc20InvalidTransferEvent() {
})
}

export async function deployOffchainLookupExample({
urls,
}: { urls: string[] }) {
return deploy({
abi: offchainLookupExampleABI,
bytecode: offchainLookupExample.bytecode.object as Hex,
account: accounts[0].address,
args: [urls],
})
}

export async function setBlockNumber(blockNumber: bigint) {
await reset(testClient, {
blockNumber,
Expand Down
44 changes: 43 additions & 1 deletion src/actions/ens/getEnsAddress.test.ts
@@ -1,5 +1,5 @@
import { beforeAll, expect, test } from 'vitest'
import { optimism } from '../../chains.js'
import { mainnet, optimism } from '../../chains.js'
import { createPublicClient, http } from '../../clients/index.js'

import {
Expand Down Expand Up @@ -48,6 +48,48 @@ test('name that looks like a hex', async () => {
)
})

test('offchain: gets address for name', async () => {
await expect(
getEnsAddress(publicClient, { name: 'jake.cb.id' }),
).resolves.toMatchInlineSnapshot(
'"0xdAb929527D862F6A75422cf40a9fb0B53059D801"',
)
})

test('offchain: name without address', async () => {
await expect(
getEnsAddress(publicClient, {
name: 'loalsdsladasdhjasgdhasjdghasgdjgasjdasd.cb.id',
}),
).resolves.toMatchInlineSnapshot('null')
})

test('offchain: aggregated', async () => {
const client = createPublicClient({
chain: mainnet,
batch: { multicall: true },
transport: http(),
})

const names = await Promise.all([
getEnsAddress(client, { name: 'jake.cb.id' }),
getEnsAddress(client, { name: 'brian.cb.id' }),
getEnsAddress(client, {
name: 'loalsdsladasdhjasgdhasjdghasgdjgasjdasd.cb.id',
}),
])

expect(names).toMatchInlineSnapshot(
`
[
"0xdAb929527D862F6A75422cf40a9fb0B53059D801",
"0xc1D9D4E2fACf0F4E72Cad1579Ac7a86598dd605D",
null,
]
`,
)
})

test('custom universal resolver address', async () => {
await expect(
getEnsAddress(publicClient, {
Expand Down
85 changes: 81 additions & 4 deletions src/actions/public/call.test.ts
Expand Up @@ -2,17 +2,27 @@ import { describe, expect, test, vi } from 'vitest'

import {
accounts,
deployOffchainLookupExample,
initialBlockNumber,
publicClient,
} from '../../_test/index.js'
import { createCcipServer } from '../../_test/ccip.js'
import { baycContractConfig, usdcContractConfig } from '../../_test/abis.js'
import { celo, mainnet } from '../../chains.js'
import { createPublicClient, http } from '../../clients/index.js'
import { aggregate3Signature } from '../../constants/index.js'
import { numberToHex, parseEther, parseGwei } from '../../utils/index.js'

import { call } from './call.js'
import {
encodeFunctionData,
numberToHex,
parseEther,
parseGwei,
trim,
} from '../../utils/index.js'

import { call, getRevertErrorData } from './call.js'
import { wait } from '../../utils/wait.js'
import { offchainLookupExampleABI } from '../../_test/generated.js'
import { BaseError, RawContractError } from '../../index.js'

const wagmiContractAddress = '0xFBA3912Ca04dd458c843e2EE08967fC04f3579c2'
const name4bytes = '0x06fdde03'
Expand All @@ -34,6 +44,52 @@ test('default', async () => {
)
})

describe('ccip', () => {
test('default', async () => {
const server = await createCcipServer()
const { contractAddress } = await deployOffchainLookupExample({
urls: [`${server.url}/{sender}/{data}`],
})

const calldata = encodeFunctionData({
abi: offchainLookupExampleABI,
functionName: 'getAddress',
args: ['jxom.viem'],
})

const { data } = await call(publicClient, {
data: calldata,
to: contractAddress!,
})

expect(trim(data!)).toEqual(accounts[0].address)

await server.close()
})

test('error: invalid signature', async () => {
const server = await createCcipServer()
const { contractAddress } = await deployOffchainLookupExample({
urls: [`${server.url}/{sender}/{data}`],
})

const calldata = encodeFunctionData({
abi: offchainLookupExampleABI,
functionName: 'getAddress',
args: ['fake.viem'],
})

await expect(() =>
call(publicClient, {
data: calldata,
to: contractAddress!,
}),
).rejects.toThrowError()

await server.close()
})
})

test('custom formatter', async () => {
const client = createPublicClient({
chain: celo,
Expand Down Expand Up @@ -205,7 +261,7 @@ describe('errors', () => {
}),
).rejects.toThrowErrorMatchingInlineSnapshot(
`
"Execution reverted for an unknown reason.
"Execution reverted with reason: Token ID is taken.
Raw Call Arguments:
from: 0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266
Expand Down Expand Up @@ -698,3 +754,24 @@ describe('batch call', () => {
{ timeout: 30_000 },
)
})

describe('getRevertErrorData', () => {
test('default', () => {
expect(getRevertErrorData(new Error('lol'))).toBe(undefined)
expect(getRevertErrorData(new BaseError('lol'))).toBe(undefined)
expect(
getRevertErrorData(
new BaseError('error', {
cause: new RawContractError({ data: '0xdeadbeef' }),
}),
),
).toBe('0xdeadbeef')
expect(
getRevertErrorData(
new BaseError('error', {
cause: new RawContractError({ data: '0x556f1830' }),
}),
),
).toBe('0x556f1830')
})
})
13 changes: 12 additions & 1 deletion src/actions/public/call.ts
@@ -1,7 +1,7 @@
import type { PublicClient, Transport } from '../../clients/index.js'
import { aggregate3Signature, multicall3Abi } from '../../constants/index.js'
import type { BaseError } from '../../errors/index.js'
import {
BaseError,
ChainDoesNotSupportContract,
ClientChainNotConfiguredError,
RawContractError,
Expand Down Expand Up @@ -30,6 +30,7 @@ import {
getCallError,
getChainContractAddress,
numberToHex,
offchainLookupSignature,
parseAccount,
} from '../../utils/index.js'
import { createBatchScheduler } from '../../utils/promise/createBatchScheduler.js'
Expand Down Expand Up @@ -159,6 +160,11 @@ export async function call<TChain extends Chain | undefined>(
if (response === '0x') return { data: undefined }
return { data: response }
} catch (err) {
const data = getRevertErrorData(err)
if (data?.slice(0, 10) === offchainLookupSignature && to) {
const { offchainLookup } = await import('../../utils/ccip.js')
return { data: await offchainLookup(client, { data, to }) }
}
throw getCallError(err as BaseError, {
...args,
account,
Expand Down Expand Up @@ -272,3 +278,8 @@ async function scheduleMulticall<TChain extends Chain | undefined>(
if (returnData === '0x') return { data: undefined }
return { data: returnData }
}

export function getRevertErrorData(err: unknown) {
if (!(err instanceof BaseError)) return undefined
return (err.walk() as { data?: Hex })?.data
}

1 comment on commit 41bc9e3

@vercel
Copy link

@vercel vercel bot commented on 41bc9e3 Apr 30, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please sign in to comment.