Skip to content
Merged
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/spotty-geckos-wonder.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@graphprotocol/graph-cli': minor
---

Add support for Sourcify contract information lookup
81 changes: 76 additions & 5 deletions packages/cli/src/command-helpers/contracts.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { describe, expect, test } from 'vitest';
import EthereumABI from '../protocols/ethereum/abi.js';
import { ContractService } from './contracts.js';
import { loadRegistry } from './registry.js';

Expand Down Expand Up @@ -85,19 +86,89 @@ const TEST_CONTRACT_START_BLOCKS = {
// },
};

describe('getStartBlockForContract', { sequential: true }, async () => {
const TEST_SOURCIFY_CONTRACT_INFO = {
mainnet: {
'0xc2EdaD668740f1aA35E4D8f227fB8E17dcA888Cd': {
name: 'MasterChef',
startBlock: 10_736_242,
},
},
optimism: {
'0xc35DADB65012eC5796536bD9864eD8773aBc74C4': {
name: 'BentoBoxV1',
startBlock: 7_019_815,
},
},
wax: {
account: {
name: null,
startBlock: null,
},
},
'non-existing chain': {
'0x0000000000000000000000000000000000000000': {
name: null,
startBlock: null,
},
},
};

// Retry helper with configurable number of retries
async function retry<T>(operation: () => Promise<T>, maxRetries = 3, sleepMs = 5000): Promise<T> {
let lastError: Error | undefined;
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
return await operation();
} catch (error) {
lastError = error as Error;
if (attempt < maxRetries - 1) {
await new Promise(resolve => setTimeout(resolve, sleepMs));
}
}
}
throw lastError;
}

describe('getStartBlockForContract', { concurrent: true }, async () => {
const registry = await loadRegistry();
const contractService = new ContractService(registry);
for (const [network, contracts] of Object.entries(TEST_CONTRACT_START_BLOCKS)) {
for (const [contract, startBlockExp] of Object.entries(contracts)) {
test(
`Returns the start block ${network} ${contract} ${startBlockExp}`,
async () => {
//loop through the TEST_CONTRACT_START_BLOCKS object and test each network
const startBlock = await contractService.getStartBlock(network, contract);
{ timeout: 50_000 },
async ({ expect }) => {
const startBlock = await retry(
() => contractService.getStartBlock(network, contract),
10,
);
expect(parseInt(startBlock)).toBe(startBlockExp);
},
{ timeout: 10_000 },
);
}
}
});

describe('getFromSourcifyForContract', { concurrent: true }, async () => {
const registry = await loadRegistry();
const contractService = new ContractService(registry);
for (const [networkId, contractInfo] of Object.entries(TEST_SOURCIFY_CONTRACT_INFO)) {
for (const [contract, t] of Object.entries(contractInfo)) {
test(
`Returns contract information ${networkId} ${contract} ${t.name} ${t.startBlock}`,
{ timeout: 50_000 },
async () => {
const result = await retry(() =>
contractService.getFromSourcify(EthereumABI, networkId, contract),
);
if (t.name === null && t.startBlock === null) {
expect(result).toBeNull();
} else {
// Only check name and startBlock, omit API property from Sourcify results
const { name, startBlock } = result!;
expect(t).toEqual({ name, startBlock: parseInt(startBlock) });
}
},
);
}
}
Expand Down
55 changes: 55 additions & 0 deletions packages/cli/src/command-helpers/contracts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,7 @@
}
}

throw new Error(`Failed to fetch deploy contract transaction for ${address}`);

Check failure on line 125 in packages/cli/src/command-helpers/contracts.ts

View workflow job for this annotation

GitHub Actions / CLI / nodejs v20

src/command-helpers/contracts.test.ts > getStartBlockForContract > Returns the start block moonbeam 0x011E52E4E40CF9498c79273329E8827b21E2e581 505060

Error: Failed to fetch deploy contract transaction for 0x011E52E4E40CF9498c79273329E8827b21E2e581 ❯ ContractService.getStartBlock src/command-helpers/contracts.ts:125:11 ❯ test.timeout src/command-helpers/contracts.test.ts:97:30
}

async getContractName(networkId: string, address: string): Promise<string> {
Expand Down Expand Up @@ -151,6 +151,61 @@
throw new Error(`Failed to fetch contract name for ${address}`);
}

async getFromSourcify(
ABICtor: typeof ABI,
networkId: string,
address: string,
): Promise<{ abi: ABI; startBlock: string; name: string } | null> {
try {
const network = this.registry.getNetworkById(networkId);
if (!network) throw new Error(`Invalid network ${networkId}`);

if (!network.caip2Id.startsWith('eip155'))
throw new Error(`Invalid chainId, Sourcify API only supports EVM chains`);

const chainId = network.caip2Id.split(':')[1];
const url = `https://sourcify.dev/server/files/any/${chainId}/${address}`;
const json:
| {
status: string;
files: { name: string; path: string; content: string }[];
}
| { error: string } = await (
await fetch(url).catch(error => {
throw new Error(`Sourcify API is unreachable: ${error}`);
})
).json();

if (json) {
if ('error' in json) throw new Error(`Sourcify API error: ${json.error}`);

let metadata: any = json.files.find(e => e.name === 'metadata.json')?.content;
if (!metadata) throw new Error('Contract is missing metadata');

const tx_hash = json.files.find(e => e.name === 'creator-tx-hash.txt')?.content;
if (!tx_hash) throw new Error('Contract is missing tx creation hash');

const tx = await this.fetchTransactionByHash(networkId, tx_hash);
if (!tx?.blockNumber)
throw new Error(`Can't fetch blockNumber from tx: ${JSON.stringify(tx)}`);

metadata = JSON.parse(metadata);
const contractName = Object.values(metadata.settings.compilationTarget)[0] as string;
return {
abi: new ABICtor(contractName, undefined, immutable.fromJS(metadata.output.abi)) as ABI,
startBlock: Number(tx.blockNumber).toString(),
name: contractName,
};
}

throw new Error(`No result: ${JSON.stringify(json)}`);
} catch (error) {
logger(`Failed to fetch from Sourcify: ${error}`);
}

return null;
}

private async fetchTransactionByHash(networkId: string, txHash: string) {
const urls = this.getRpcUrls(networkId);
if (!urls.length) {
Expand Down
16 changes: 14 additions & 2 deletions packages/cli/src/commands/add.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,12 +80,24 @@ export default class AddCommand extends Command {
if (isLocalHost) this.warn('`localhost` network detected, prompting user for inputs');
const registry = await loadRegistry();
const contractService = new ContractService(registry);
const sourcifyContractInfo = await contractService.getFromSourcify(
EthereumABI,
network,
address,
);

let startBlock = startBlockFlag ? parseInt(startBlockFlag).toString() : startBlockFlag;
let contractName = contractNameFlag || DEFAULT_CONTRACT_NAME;

let ethabi = null;
if (abi) {

if (sourcifyContractInfo) {
startBlock ??= sourcifyContractInfo.startBlock;
contractName =
contractName == DEFAULT_CONTRACT_NAME ? sourcifyContractInfo.name : contractName;
ethabi ??= sourcifyContractInfo.abi;
}

if (!ethabi && abi) {
ethabi = EthereumABI.load(contractName, abi);
} else {
try {
Expand Down
29 changes: 27 additions & 2 deletions packages/cli/src/commands/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,11 @@ export default class InitCommand extends Command {
if ((fromContract || spkgPath) && protocol && subgraphName && directory && network && node) {
const registry = await loadRegistry();
const contractService = new ContractService(registry);
const sourcifyContractInfo = await contractService.getFromSourcify(
EthereumABI,
network,
fromContract!,
);

if (!protocolChoices.includes(protocol as ProtocolName)) {
this.error(
Expand All @@ -222,7 +227,9 @@ export default class InitCommand extends Command {
}
} else {
try {
abi = await contractService.getABI(ABI, network, fromContract!);
abi = sourcifyContractInfo
? sourcifyContractInfo.abi
: await contractService.getABI(ABI, network, fromContract!);
} catch (e) {
this.exit(1);
}
Expand Down Expand Up @@ -448,7 +455,7 @@ async function processInitForm(
];
};

let network = networks[0];
let network: Network = networks[0];
let protocolInstance: Protocol = new Protocol('ethereum');
let isComposedSubgraph = false;
let isSubstreams = false;
Expand Down Expand Up @@ -611,6 +618,22 @@ async function processInitForm(
return address;
}

const sourcifyContractInfo = await contractService.getFromSourcify(
EthereumABI,
network.id,
address,
);
if (sourcifyContractInfo) {
initStartBlock ??= sourcifyContractInfo.startBlock;
initContractName ??= sourcifyContractInfo.name;
initAbi ??= sourcifyContractInfo.abi;
initDebugger.extend('processInitForm')(
"infoFromSourcify: '%s'/'%s'",
initStartBlock,
initContractName,
);
}

// If ABI is not provided, try to fetch it from Etherscan API
if (protocolInstance.hasABIs() && !initAbi) {
abiFromApi = await retryWithPrompt(() =>
Expand All @@ -622,6 +645,8 @@ async function processInitForm(
),
);
initDebugger.extend('processInitForm')("abiFromEtherscan len: '%s'", abiFromApi?.name);
} else {
abiFromApi = initAbi;
}
// If startBlock is not provided, try to fetch it from Etherscan API
if (!initStartBlock) {
Expand Down
Loading