Skip to content
New issue

Have a question about this project? # for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “#”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? # to your account

fix(cli): foundry plugin support for multiple addresses with same ABI #4410

Open
wants to merge 5 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/plenty-lemons-leave.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@wagmi/cli": major
---

Fixed Foundry plugin to properly handle multiple addresses for the same ABI (e.g., different ERC20 tokens).
150 changes: 150 additions & 0 deletions packages/cli/src/plugins/foundry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,88 @@ test('contracts', () => {
`)
})

test('contracts with multiple addresses for same ABI', () => {
expect(
foundry({
project: resolve(__dirname, '__fixtures__/foundry/'),
exclude: ['Counter.sol/**'],
deployments: {
Foo: {
Token1: '0x1234567890123456789012345678901234567890',
Token2: '0x2345678901234567890123456789012345678901',
},
},
}).contracts?.(),
).resolves.toMatchInlineSnapshot(`
[
{
"abi": [
{
"inputs": [],
"name": "bar",
"outputs": [
{
"internalType": "string",
"name": "",
"type": "string",
},
],
"stateMutability": "view",
"type": "function",
},
{
"inputs": [
{
"internalType": "string",
"name": "baz",
"type": "string",
},
],
"name": "setFoo",
"outputs": [],
"stateMutability": "nonpayable",
"type": "function",
},
],
"address": "0x1234567890123456789012345678901234567890",
"name": "Foo_Token1",
},
{
"abi": [
{
"inputs": [],
"name": "bar",
"outputs": [
{
"internalType": "string",
"name": "",
"type": "string",
},
],
"stateMutability": "view",
"type": "function",
},
{
"inputs": [
{
"internalType": "string",
"name": "baz",
"type": "string",
},
],
"name": "setFoo",
"outputs": [],
"stateMutability": "nonpayable",
"type": "function",
},
],
"address": "0x2345678901234567890123456789012345678901",
"name": "Foo_Token2",
},
]
`)
})

test('contracts without project', async () => {
const dir = resolve(__dirname, '__fixtures__/foundry/')
const spy = vi.spyOn(process, 'cwd')
Expand Down Expand Up @@ -151,3 +233,71 @@ test('contracts without project', async () => {
]
`)
})

test('watch handlers with multiple address deployments', async () => {
const dir = resolve(__dirname, '__fixtures__/foundry/')
const plugin = foundry({
project: dir,
deployments: {
Foo: {
Token1: '0x1234567890123456789012345678901234567890',
Token2: '0x2345678901234567890123456789012345678901',
},
},
})

const path = resolve(dir, 'out/Foo.sol/Foo.json')

if (
!plugin.watch?.onAdd ||
!plugin.watch?.onChange ||
!plugin.watch?.onRemove
) {
throw new Error('Watch handlers not properly configured')
}

// Test onAdd handler
const addResult = await plugin.watch.onAdd(path)
expect(addResult).toMatchInlineSnapshot(`
{
"abi": [
{
"inputs": [],
"name": "bar",
"outputs": [
{
"internalType": "string",
"name": "",
"type": "string",
},
],
"stateMutability": "view",
"type": "function",
},
{
"inputs": [
{
"internalType": "string",
"name": "baz",
"type": "string",
},
],
"name": "setFoo",
"outputs": [],
"stateMutability": "nonpayable",
"type": "function",
},
],
"address": "0x1234567890123456789012345678901234567890",
"name": "Foo_Token1",
}
`)

// Test onChange handler
const changeResult = await plugin.watch.onChange(path)
expect(changeResult).toEqual(addResult)

// Test onRemove handler
const removeResult = await plugin.watch.onRemove(path)
expect(removeResult).toBe('Foo')
})
88 changes: 73 additions & 15 deletions packages/cli/src/plugins/foundry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,8 +52,17 @@ export type FoundryConfig = {
* @default foundry.config#out | 'out'
*/
artifacts?: string | undefined
/** Mapping of addresses to attach to artifacts. */
deployments?: { [key: string]: ContractConfig['address'] } | undefined
/**
* Mapping of addresses to attach to artifacts.
* Can be either a single address or a chain-id mapped address
*/
deployments?:
| {
[contractName: string]:
| ContractConfig['address']
| Record<string, ContractConfig['address']>
}
| undefined
/** Artifact files to exclude. */
exclude?: string[] | undefined
/** [Forge](https://book.getfoundry.sh/forge) configuration */
Expand Down Expand Up @@ -124,13 +133,51 @@ export function foundry(config: FoundryConfig = {}): FoundryResult {
return `${usePrefix ? namePrefix : ''}${filename.replace(extension, '')}`
}

async function getContract(artifactPath: string) {
async function getContract(
artifactPath: string,
): Promise<ContractConfig | ContractConfig[]> {
const artifact = await fs.readJSON(artifactPath)
const baseName = getContractName(artifactPath, false)
const deployment = deployments[baseName]

// Check if ABI exists and is an array
if (!artifact.abi || !Array.isArray(artifact.abi)) {
return {
abi: [],
address: deployment as ContractConfig['address'],
name: getContractName(artifactPath),
}
}

// Sort ABI to ensure consistent order
const sortedAbi = [...artifact.abi].sort((a, b) => {
if (a.type !== b.type) return a.type.localeCompare(b.type)
if ('name' in a && 'name' in b) return a.name.localeCompare(b.name)
return 0
})

// Handle case where deployment is a record of multiple addresses
if (
deployment &&
typeof deployment === 'object' &&
!('address' in deployment)
) {
// Create separate contracts for each deployment address
const contracts: ContractConfig[] = []
for (const [key, address] of Object.entries(deployment)) {
contracts.push({
abi: sortedAbi,
address: address as ContractConfig['address'],
name: `${baseName}_${key}`,
})
}
return contracts
}

// Handle single address case
return {
abi: artifact.abi,
address: (deployments as Record<string, ContractConfig['address']>)[
getContractName(artifactPath, false)
],
abi: sortedAbi,
address: deployment as ContractConfig['address'],
name: getContractName(artifactPath),
}
}
Expand Down Expand Up @@ -179,9 +226,18 @@ export function foundry(config: FoundryConfig = {}): FoundryResult {
const artifactPaths = await getArtifactPaths(artifactsDirectory)
const contracts = []
for (const artifactPath of artifactPaths) {
const contract = await getContract(artifactPath)
if (!contract.abi?.length) continue
contracts.push(contract)
const result = await getContract(artifactPath)
if (Array.isArray(result)) {
// Handle multiple contracts case
for (const contract of result) {
if (!contract.abi?.length) continue
contracts.push(contract)
}
} else {
// Handle single contract case
if (!result.abi?.length) continue
contracts.push(result)
}
}
return contracts
},
Expand Down Expand Up @@ -231,13 +287,15 @@ export function foundry(config: FoundryConfig = {}): FoundryResult {
...include.map((x) => `${artifactsDirectory}/**/${x}`),
...exclude.map((x) => `!${artifactsDirectory}/**/${x}`),
],
async onAdd(path) {
return getContract(path)
async onAdd(path): Promise<ContractConfig | undefined> {
const result = await getContract(path)
return Array.isArray(result) ? result[0] : result
},
async onChange(path) {
return getContract(path)
async onChange(path): Promise<ContractConfig | undefined> {
const result = await getContract(path)
return Array.isArray(result) ? result[0] : result
},
async onRemove(path) {
async onRemove(path): Promise<string | undefined> {
return getContractName(path)
},
},
Expand Down
Loading