Skip to content

Commit 8cdaf31

Browse files
0237hYaroShkvorets
andauthoredJan 21, 2025··
Add support for Sourcify contract information lookup (#1899)
* Add support for Sourcify contract information lookup - Contract name, ABI and creation transaction hash (start block) from [Sourcify API](https://docs.sourcify.dev/docs/api/). - Runs before the registry lookup and replaces default values (not interactive) if not provided by the user. This means priority for CLI parameters looks like: user submitted (env/CLI args) > Sourcify API > Default values > Registry fetch * Fix incorrect ABI instantiation for `init` with parameters * Add changeset * Throw early for non-EVM chains * Add tests for contract name and startBlock info * Add test for non-evm contract lookup * removed hardcoded wax check, added test * add retries to tests --------- Co-authored-by: YaroShkvorets <shkvorets@gmail.com>
1 parent bcaad5e commit 8cdaf31

File tree

5 files changed

+177
-9
lines changed

5 files changed

+177
-9
lines changed
 

‎.changeset/spotty-geckos-wonder.md

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@graphprotocol/graph-cli': minor
3+
---
4+
5+
Add support for Sourcify contract information lookup

‎packages/cli/src/command-helpers/contracts.test.ts

+76-5
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { describe, expect, test } from 'vitest';
2+
import EthereumABI from '../protocols/ethereum/abi.js';
23
import { ContractService } from './contracts.js';
34
import { loadRegistry } from './registry.js';
45

@@ -85,19 +86,89 @@ const TEST_CONTRACT_START_BLOCKS = {
8586
// },
8687
};
8788

88-
describe('getStartBlockForContract', { sequential: true }, async () => {
89+
const TEST_SOURCIFY_CONTRACT_INFO = {
90+
mainnet: {
91+
'0xc2EdaD668740f1aA35E4D8f227fB8E17dcA888Cd': {
92+
name: 'MasterChef',
93+
startBlock: 10_736_242,
94+
},
95+
},
96+
optimism: {
97+
'0xc35DADB65012eC5796536bD9864eD8773aBc74C4': {
98+
name: 'BentoBoxV1',
99+
startBlock: 7_019_815,
100+
},
101+
},
102+
wax: {
103+
account: {
104+
name: null,
105+
startBlock: null,
106+
},
107+
},
108+
'non-existing chain': {
109+
'0x0000000000000000000000000000000000000000': {
110+
name: null,
111+
startBlock: null,
112+
},
113+
},
114+
};
115+
116+
// Retry helper with configurable number of retries
117+
async function retry<T>(operation: () => Promise<T>, maxRetries = 3, sleepMs = 5000): Promise<T> {
118+
let lastError: Error | undefined;
119+
for (let attempt = 0; attempt < maxRetries; attempt++) {
120+
try {
121+
return await operation();
122+
} catch (error) {
123+
lastError = error as Error;
124+
if (attempt < maxRetries - 1) {
125+
await new Promise(resolve => setTimeout(resolve, sleepMs));
126+
}
127+
}
128+
}
129+
throw lastError;
130+
}
131+
132+
describe('getStartBlockForContract', { concurrent: true }, async () => {
89133
const registry = await loadRegistry();
90134
const contractService = new ContractService(registry);
91135
for (const [network, contracts] of Object.entries(TEST_CONTRACT_START_BLOCKS)) {
92136
for (const [contract, startBlockExp] of Object.entries(contracts)) {
93137
test(
94138
`Returns the start block ${network} ${contract} ${startBlockExp}`,
95-
async () => {
96-
//loop through the TEST_CONTRACT_START_BLOCKS object and test each network
97-
const startBlock = await contractService.getStartBlock(network, contract);
139+
{ timeout: 50_000 },
140+
async ({ expect }) => {
141+
const startBlock = await retry(
142+
() => contractService.getStartBlock(network, contract),
143+
10,
144+
);
98145
expect(parseInt(startBlock)).toBe(startBlockExp);
99146
},
100-
{ timeout: 10_000 },
147+
);
148+
}
149+
}
150+
});
151+
152+
describe('getFromSourcifyForContract', { concurrent: true }, async () => {
153+
const registry = await loadRegistry();
154+
const contractService = new ContractService(registry);
155+
for (const [networkId, contractInfo] of Object.entries(TEST_SOURCIFY_CONTRACT_INFO)) {
156+
for (const [contract, t] of Object.entries(contractInfo)) {
157+
test(
158+
`Returns contract information ${networkId} ${contract} ${t.name} ${t.startBlock}`,
159+
{ timeout: 50_000 },
160+
async () => {
161+
const result = await retry(() =>
162+
contractService.getFromSourcify(EthereumABI, networkId, contract),
163+
);
164+
if (t.name === null && t.startBlock === null) {
165+
expect(result).toBeNull();
166+
} else {
167+
// Only check name and startBlock, omit API property from Sourcify results
168+
const { name, startBlock } = result!;
169+
expect(t).toEqual({ name, startBlock: parseInt(startBlock) });
170+
}
171+
},
101172
);
102173
}
103174
}

‎packages/cli/src/command-helpers/contracts.ts

+55
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,61 @@ export class ContractService {
151151
throw new Error(`Failed to fetch contract name for ${address}`);
152152
}
153153

154+
async getFromSourcify(
155+
ABICtor: typeof ABI,
156+
networkId: string,
157+
address: string,
158+
): Promise<{ abi: ABI; startBlock: string; name: string } | null> {
159+
try {
160+
const network = this.registry.getNetworkById(networkId);
161+
if (!network) throw new Error(`Invalid network ${networkId}`);
162+
163+
if (!network.caip2Id.startsWith('eip155'))
164+
throw new Error(`Invalid chainId, Sourcify API only supports EVM chains`);
165+
166+
const chainId = network.caip2Id.split(':')[1];
167+
const url = `https://sourcify.dev/server/files/any/${chainId}/${address}`;
168+
const json:
169+
| {
170+
status: string;
171+
files: { name: string; path: string; content: string }[];
172+
}
173+
| { error: string } = await (
174+
await fetch(url).catch(error => {
175+
throw new Error(`Sourcify API is unreachable: ${error}`);
176+
})
177+
).json();
178+
179+
if (json) {
180+
if ('error' in json) throw new Error(`Sourcify API error: ${json.error}`);
181+
182+
let metadata: any = json.files.find(e => e.name === 'metadata.json')?.content;
183+
if (!metadata) throw new Error('Contract is missing metadata');
184+
185+
const tx_hash = json.files.find(e => e.name === 'creator-tx-hash.txt')?.content;
186+
if (!tx_hash) throw new Error('Contract is missing tx creation hash');
187+
188+
const tx = await this.fetchTransactionByHash(networkId, tx_hash);
189+
if (!tx?.blockNumber)
190+
throw new Error(`Can't fetch blockNumber from tx: ${JSON.stringify(tx)}`);
191+
192+
metadata = JSON.parse(metadata);
193+
const contractName = Object.values(metadata.settings.compilationTarget)[0] as string;
194+
return {
195+
abi: new ABICtor(contractName, undefined, immutable.fromJS(metadata.output.abi)) as ABI,
196+
startBlock: Number(tx.blockNumber).toString(),
197+
name: contractName,
198+
};
199+
}
200+
201+
throw new Error(`No result: ${JSON.stringify(json)}`);
202+
} catch (error) {
203+
logger(`Failed to fetch from Sourcify: ${error}`);
204+
}
205+
206+
return null;
207+
}
208+
154209
private async fetchTransactionByHash(networkId: string, txHash: string) {
155210
const urls = this.getRpcUrls(networkId);
156211
if (!urls.length) {

‎packages/cli/src/commands/add.ts

+14-2
Original file line numberDiff line numberDiff line change
@@ -80,12 +80,24 @@ export default class AddCommand extends Command {
8080
if (isLocalHost) this.warn('`localhost` network detected, prompting user for inputs');
8181
const registry = await loadRegistry();
8282
const contractService = new ContractService(registry);
83+
const sourcifyContractInfo = await contractService.getFromSourcify(
84+
EthereumABI,
85+
network,
86+
address,
87+
);
8388

8489
let startBlock = startBlockFlag ? parseInt(startBlockFlag).toString() : startBlockFlag;
8590
let contractName = contractNameFlag || DEFAULT_CONTRACT_NAME;
86-
8791
let ethabi = null;
88-
if (abi) {
92+
93+
if (sourcifyContractInfo) {
94+
startBlock ??= sourcifyContractInfo.startBlock;
95+
contractName =
96+
contractName == DEFAULT_CONTRACT_NAME ? sourcifyContractInfo.name : contractName;
97+
ethabi ??= sourcifyContractInfo.abi;
98+
}
99+
100+
if (!ethabi && abi) {
89101
ethabi = EthereumABI.load(contractName, abi);
90102
} else {
91103
try {

‎packages/cli/src/commands/init.ts

+27-2
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,11 @@ export default class InitCommand extends Command {
200200
if ((fromContract || spkgPath) && protocol && subgraphName && directory && network && node) {
201201
const registry = await loadRegistry();
202202
const contractService = new ContractService(registry);
203+
const sourcifyContractInfo = await contractService.getFromSourcify(
204+
EthereumABI,
205+
network,
206+
fromContract!,
207+
);
203208

204209
if (!protocolChoices.includes(protocol as ProtocolName)) {
205210
this.error(
@@ -222,7 +227,9 @@ export default class InitCommand extends Command {
222227
}
223228
} else {
224229
try {
225-
abi = await contractService.getABI(ABI, network, fromContract!);
230+
abi = sourcifyContractInfo
231+
? sourcifyContractInfo.abi
232+
: await contractService.getABI(ABI, network, fromContract!);
226233
} catch (e) {
227234
this.exit(1);
228235
}
@@ -448,7 +455,7 @@ async function processInitForm(
448455
];
449456
};
450457

451-
let network = networks[0];
458+
let network: Network = networks[0];
452459
let protocolInstance: Protocol = new Protocol('ethereum');
453460
let isComposedSubgraph = false;
454461
let isSubstreams = false;
@@ -611,6 +618,22 @@ async function processInitForm(
611618
return address;
612619
}
613620

621+
const sourcifyContractInfo = await contractService.getFromSourcify(
622+
EthereumABI,
623+
network.id,
624+
address,
625+
);
626+
if (sourcifyContractInfo) {
627+
initStartBlock ??= sourcifyContractInfo.startBlock;
628+
initContractName ??= sourcifyContractInfo.name;
629+
initAbi ??= sourcifyContractInfo.abi;
630+
initDebugger.extend('processInitForm')(
631+
"infoFromSourcify: '%s'/'%s'",
632+
initStartBlock,
633+
initContractName,
634+
);
635+
}
636+
614637
// If ABI is not provided, try to fetch it from Etherscan API
615638
if (protocolInstance.hasABIs() && !initAbi) {
616639
abiFromApi = await retryWithPrompt(() =>
@@ -622,6 +645,8 @@ async function processInitForm(
622645
),
623646
);
624647
initDebugger.extend('processInitForm')("abiFromEtherscan len: '%s'", abiFromApi?.name);
648+
} else {
649+
abiFromApi = initAbi;
625650
}
626651
// If startBlock is not provided, try to fetch it from Etherscan API
627652
if (!initStartBlock) {

0 commit comments

Comments
 (0)
Please sign in to comment.