Skip to content
Merged
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
237 changes: 144 additions & 93 deletions packages/cli/src/command-helpers/contracts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,23 @@ import ABI from '../protocols/ethereum/abi.js';

const logger = debugFactory('graph-cli:contract-service');

function withTimeout<T>(promise: Promise<T>, timeoutMs: number = 10_000): Promise<T> {
return Promise.race([
promise,
new Promise<T>((_, reject) =>
setTimeout(() => reject(new Error('Request timed out')), timeoutMs),
),
]);
}
export class ContractService {
constructor(private registry: NetworksRegistry) {}

private async fetchFromEtherscan(url: string): Promise<any | null> {
const result = await fetch(url).catch(_error => {
throw new Error(`Contract API is unreachable`);
});
const result = await withTimeout(
fetch(url).catch(_error => {
throw new Error(`Contract API is unreachable`);
}),
);
let json: any = {};

if (result.ok) {
Expand All @@ -30,10 +40,13 @@ export class ContractService {
result.url,
json,
);
if (!result.ok) {
throw new Error(`${result.status} ${result.statusText}`);
}
if (json.message) {
throw new Error(`${json.message ?? ''} - ${json.result ?? ''}`);
throw new Error(`${json.message} - ${json.result ?? ''}`);
}
return null;
throw new Error('Empty response');
}

// replace {api_key} with process.env[api_key]
Expand Down Expand Up @@ -71,84 +84,107 @@ export class ContractService {

async getABI(ABICtor: typeof ABI, networkId: string, address: string) {
const urls = this.getEtherscanUrls(networkId);
const errors: string[] = [];
if (!urls.length) {
throw new Error(`No contract API available for ${networkId} in the registry`);
}
for (const url of urls) {
try {
const json = await this.fetchFromEtherscan(
`${url}?module=contract&action=getabi&address=${address}`,
);

if (json) {
return new ABICtor('Contract', undefined, immutable.fromJS(JSON.parse(json.result)));

try {
const result = await Promise.any(
urls.map(url =>
this.fetchFromEtherscan(`${url}?module=contract&action=getabi&address=${address}`).then(
json => {
if (!json?.result) {
throw new Error(`No result: ${JSON.stringify(json ?? {})}`);
}
return new ABICtor('Contract', undefined, immutable.fromJS(JSON.parse(json.result)));
},
),
),
);
return result;
} catch (error) {
if (error instanceof AggregateError) {
for (const err of error.errors) {
logger(`Failed to fetch ABI: ${err}`);
}
throw new Error(`no result: ${JSON.stringify(json)}`);
} catch (error) {
logger(`Failed to fetch from ${url}: ${error}`);
errors.push(String(error));
throw new Error(`Failed to fetch ABI: ${error.errors?.[0] ?? 'no public RPC endpoints'}`);
}
throw error;
}

throw new Error(errors?.[0]);
}

async getStartBlock(networkId: string, address: string): Promise<string> {
const urls = this.getEtherscanUrls(networkId);
if (!urls.length) {
throw new Error(`No contract API available for ${networkId} in the registry`);
}
for (const url of urls) {
try {
const json = await this.fetchFromEtherscan(
`${url}?module=contract&action=getcontractcreation&contractaddresses=${address}`,
);

if (json?.result?.length) {
if (json.result[0]?.blockNumber) {
return json.result[0].blockNumber;
}
const txHash = json.result[0].txHash;
const tx = await this.fetchTransactionByHash(networkId, txHash);
if (!tx?.blockNumber) {
throw new Error(`no blockNumber: ${JSON.stringify(tx)}`);
}
return Number(tx.blockNumber).toString();

try {
const result = await Promise.any(
urls.map(url =>
this.fetchFromEtherscan(
`${url}?module=contract&action=getcontractcreation&contractaddresses=${address}`,
).then(async json => {
if (!json?.result?.length) {
throw new Error(`No result: ${JSON.stringify(json)}`);
}
if (json.result[0]?.blockNumber) {
return json.result[0].blockNumber;
}
const txHash = json.result[0].txHash;
const tx = await this.fetchTransactionByHash(networkId, txHash);
if (!tx?.blockNumber) {
throw new Error(`No block number: ${JSON.stringify(tx)}`);
}
return Number(tx.blockNumber).toString();
}),
),
);
return result;
} catch (error) {
if (error instanceof AggregateError) {
for (const err of error.errors) {
logger(`Failed to fetch start block: ${err}`);
}
throw new Error(`no result: ${JSON.stringify(json)}`);
} catch (error) {
logger(`Failed to fetch start block from ${url}: ${error}`);
throw new Error(`Failed to fetch contract deployment transaction`);
}
throw error;
}

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

async getContractName(networkId: string, address: string): Promise<string> {
const urls = this.getEtherscanUrls(networkId);
if (!urls.length) {
throw new Error(`No contract API available for ${networkId} in the registry`);
}
for (const url of urls) {
try {
const json = await this.fetchFromEtherscan(
`${url}?module=contract&action=getsourcecode&address=${address}`,
);

if (json) {
const { ContractName } = json.result[0];
if (ContractName !== '') {

try {
const result = await Promise.any(
urls.map(url =>
this.fetchFromEtherscan(
`${url}?module=contract&action=getsourcecode&address=${address}`,
).then(json => {
if (!json?.result?.length) {
throw new Error(`No result: ${JSON.stringify(json)}`);
}
const { ContractName } = json.result[0];
if (!ContractName) {
throw new Error('Contract name is empty');
}
return ContractName;
}
}),
),
);
return result;
} catch (error) {
if (error instanceof AggregateError) {
for (const err of error.errors) {
logger(`Failed to fetch contract name: ${err}`);
}
throw new Error(`no result: ${JSON.stringify(json)}`);
} catch (error) {
logger(`Failed to fetch from ${url}: ${error}`);
throw new Error(`Name not found`);
}
throw error;
}

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

async getFromSourcify(
Expand All @@ -168,32 +204,35 @@ export class ContractService {

const chainId = network.caip2Id.split(':')[1];
const url = `https://sourcify.dev/server/v2/contract/${chainId}/${address}?fields=abi,compilation,deployment`;
const resp = await fetch(url).catch(error => {
throw new Error(`Sourcify API is unreachable: ${error}`);
});
const resp = await withTimeout(
fetch(url).catch(error => {
throw new Error(`Sourcify API is unreachable: ${error}`);
}),
);
if (resp.status === 404) throw new Error(`Sourcify API says contract is not verified`);
if (!resp.ok) throw new Error(`Sourcify API returned status ${resp.status}`);
const json: {
abi: any[];
compilation: { name: string };
deployment: { blockNumber: string };
} = await resp.json();

if (json) {
const abi = json.abi;
const contractName = json.compilation?.name;
const blockNumber = json.deployment?.blockNumber;

if (!abi || !contractName || !blockNumber) throw new Error('Contract is missing metadata');
} = await resp.json().catch(error => {
throw new Error(`Invalid Sourcify response: ${error}`);
});

return {
abi: new ABICtor(contractName, undefined, immutable.fromJS(abi)) as ABI,
startBlock: Number(blockNumber).toString(),
name: contractName,
};
if (!json) {
throw new Error(`No result`);
}
const abi = json.abi;
const contractName = json.compilation?.name;
const blockNumber = json.deployment?.blockNumber;

if (!abi || !contractName || !blockNumber) throw new Error('Contract is missing metadata');

throw new Error(`No result: ${JSON.stringify(json)}`);
return {
abi: new ABICtor(contractName, undefined, immutable.fromJS(abi)) as ABI,
startBlock: Number(blockNumber).toString(),
name: contractName,
};
} catch (error) {
logger(`Failed to fetch from Sourcify: ${error}`);
}
Expand All @@ -206,29 +245,41 @@ export class ContractService {
if (!urls.length) {
throw new Error(`No JSON-RPC available for ${networkId} in the registry`);
}
for (const url of urls) {
try {
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
jsonrpc: '2.0',
method: 'eth_getTransactionByHash',
params: [txHash],
id: 1,
}),
});

const json = await response.json();
if (json.result) {
return json.result;
try {
const result = await Promise.any(
urls.map(url =>
withTimeout(
fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
jsonrpc: '2.0',
method: 'eth_getTransactionByHash',
params: [txHash],
id: 1,
}),
})
.then(response => response.json())
.then(json => {
if (!json?.result) {
throw new Error(JSON.stringify(json));
}
return json.result;
}),
),
),
);
return result;
} catch (error) {
if (error instanceof AggregateError) {
// All promises were rejected
for (const err of error.errors) {
logger(`Failed to fetch tx ${txHash}: ${err}`);
}
throw new Error(JSON.stringify(json));
} catch (error) {
logger(`Failed to fetch tx ${txHash} from ${url}: ${error}`);
throw new Error(`Failed to fetch transaction ${txHash}`);
}
throw error;
}

throw new Error(`JSON-RPC is unreachable`);
}
}