Skip to content
Open
84 changes: 84 additions & 0 deletions packages/cli/src/plugins/foundry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -407,3 +407,87 @@ test('watch callbacks use broadcast deployments', async () => {
},
})
})

test('deployments: multiple addresses sharing same ABI via DisplayName:ArtifactName', async () => {
const dir = await createTempDir()
const spy = vi.spyOn(process, 'cwd')
spy.mockImplementation(() => dir)

const artifactsDir = resolve(dir, 'out')
await fs.mkdir(artifactsDir, { recursive: true })

const erc20Abi = [
{
inputs: [{ internalType: 'address', name: 'account', type: 'address' }],
name: 'balanceOf',
outputs: [{ internalType: 'uint256', name: '', type: 'uint256' }],
stateMutability: 'view',
type: 'function',
},
]
await fs.writeFile(
resolve(artifactsDir, 'ERC20.json'),
JSON.stringify({ abi: erc20Abi }, null, 2),
)

const contracts = await foundry({
deployments: {
'DAI:ERC20': { 1: '0x6B175474E89094C44Da98b954EedeAC495271d0F' },
'WETH:ERC20': { 1: '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2' },
},
forge: { build: false },
}).contracts?.()

expect(contracts).toHaveLength(2)

const dai = contracts?.find((c) => c.name === 'DAI')
const weth = contracts?.find((c) => c.name === 'WETH')

expect(dai).toBeDefined()
expect(dai?.abi).toEqual(erc20Abi)
expect(dai?.address).toEqual({
1: '0x6B175474E89094C44Da98b954EedeAC495271d0F',
})

expect(weth).toBeDefined()
expect(weth?.abi).toEqual(erc20Abi)
expect(weth?.address).toEqual({
1: '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2',
})
})

test('deployments: single DisplayName:ArtifactName alias', async () => {
const dir = await createTempDir()
const spy = vi.spyOn(process, 'cwd')
spy.mockImplementation(() => dir)

const artifactsDir = resolve(dir, 'out')
await fs.mkdir(artifactsDir, { recursive: true })

const erc20Abi = [
{
inputs: [{ internalType: 'address', name: 'account', type: 'address' }],
name: 'balanceOf',
outputs: [{ internalType: 'uint256', name: '', type: 'uint256' }],
stateMutability: 'view',
type: 'function',
},
]
await fs.writeFile(
resolve(artifactsDir, 'ERC20.json'),
JSON.stringify({ abi: erc20Abi }, null, 2),
)

const contracts = await foundry({
deployments: {
'DAI:ERC20': { 1: '0x6B175474E89094C44Da98b954EedeAC495271d0F' },
},
forge: { build: false },
}).contracts?.()

expect(contracts).toHaveLength(1)
expect(contracts?.[0]?.name).toBe('DAI')
expect(contracts?.[0]?.address).toEqual({
1: '0x6B175474E89094C44Da98b954EedeAC495271d0F',
})
})
53 changes: 49 additions & 4 deletions packages/cli/src/plugins/foundry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,24 @@ export type FoundryConfig = {
* @default false
*/
includeBroadcasts?: boolean | undefined
/** Mapping of addresses to attach to artifacts. */
/**
* Mapping of addresses to attach to artifacts.
*
* Keys are artifact names (e.g. `'ERC20'`). To generate multiple named contracts
* from the same ABI, use `'DisplayName:ArtifactName'` — each entry produces a
* separate export that shares the ABI but gets its own address and name.
*
* @example
* // Single deployment
* { ERC20: { 1: '0x6B175474E89094C44Da98b954EedeAC495271d0F' } }
*
* @example
* // Multiple tokens sharing the same ERC20 ABI
* {
* 'DAI:ERC20': { 1: '0x6B175474E89094C44Da98b954EedeAC495271d0F' },
* 'WETH:ERC20': { 1: '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2' },
* }
*/
deployments?: { [key: string]: ContractConfig['address'] } | undefined
/** Artifact files to exclude. */
exclude?: string[] | undefined
Expand Down Expand Up @@ -149,6 +166,22 @@ export function foundry(config: FoundryConfig = {}): FoundryResult {
}
}

function getDeploymentsForArtifact(
artifactName: string,
contractDeployments: { [key: string]: ContractConfig['address'] },
): Array<{ displayName: string; address: ContractConfig['address'] }> {
const direct = contractDeployments[artifactName]
if (direct !== undefined)
return [{ displayName: artifactName, address: direct }]

return Object.entries(contractDeployments)
.filter(([key]) => key.endsWith(`:${artifactName}`))
.map(([key, address]) => ({
displayName: key.slice(0, -(artifactName.length + 1)),
address,
}))
}

function getArtifactPaths(artifactsDirectory: string) {
const crawler = new fdir().withBasePath().globWithOptions(
include.map((x) => `${artifactsDirectory}/**/${x}`),
Expand Down Expand Up @@ -274,9 +307,21 @@ export function foundry(config: FoundryConfig = {}): FoundryResult {
const artifactPaths = await getArtifactPaths(artifactsDirectory)
const contracts = []
for (const artifactPath of artifactPaths) {
const contract = await getContract(artifactPath, allDeployments)
if (!contract.abi?.length) continue
contracts.push(contract)
const artifact = JSON.parse(await readFile(artifactPath, 'utf8'))
if (!artifact.abi?.length) continue
const artifactName = getContractName(artifactPath, false)
const matches = getDeploymentsForArtifact(artifactName, allDeployments)
if (matches.length >= 1 && matches[0]!.displayName !== artifactName) {
for (const match of matches) {
contracts.push({
abi: artifact.abi,
address: match.address,
name: `${namePrefix}${match.displayName}`,
})
}
} else {
contracts.push(await getContract(artifactPath, allDeployments))
}
}
return contracts
},
Expand Down