Skip to content

Commit 49800b7

Browse files
authored
Add getUSDCAttestationStatus command (#48)
2 parents 61ddf1f + 7f53943 commit 49800b7

6 files changed

Lines changed: 542 additions & 1 deletion

File tree

Lines changed: 272 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,272 @@
1+
import { type TransactionReceipt, EventFragment } from 'ethers'
2+
import {
3+
bigIntReplacer,
4+
defaultAbiCoder,
5+
getUsdcAttestation,
6+
getUsdcAttestationV2,
7+
} from '../lib/index.ts'
8+
import type { Providers } from '../providers.ts'
9+
import { Format } from './types.ts'
10+
import { withDateTimestamp } from './utils.ts'
11+
12+
// Circle CCTP Domain ID mapping based on https://developers.circle.com/cctp/cctp-supported-blockchains#cctp-v2-supported-domains
13+
const MAINNET_CHAIN_ID_TO_CIRCLE_DOMAIN: Record<number, number> = {
14+
1: 0, // Ethereum
15+
43114: 1, // Avalanche
16+
10: 2, // OP Mainnet
17+
42161: 3, // Arbitrum
18+
8453: 6, // Base
19+
137: 7, // Polygon PoS
20+
1829: 10, // Unichain
21+
59144: 11, // Linea
22+
81457: 12, // Codex
23+
146: 13, // Sonic
24+
480: 14, // World Chain
25+
1329: 16, // Sei
26+
56: 17, // BNB Smart Chain
27+
}
28+
29+
const TESTNET_CHAIN_ID_TO_CIRCLE_DOMAIN: Record<number, number> = {
30+
421614: 3, // Arbitrum Sepolia
31+
43113: 1, // Avalanche Fuji
32+
84532: 6, // Base Sepolia
33+
97: 17, // BNB Smart Chain Testnet
34+
11155111: 0, // Ethereum Sepolia
35+
59141: 11, // Linea Sepolia
36+
11155420: 2, // OP Sepolia
37+
80002: 7, // Polygon PoS Amoy
38+
713715: 16, // Sei Testnet
39+
1919: 10, // Unichain Sepolia
40+
4801: 14, // World Chain Sepolia
41+
51: 50, // XDC Apothem
42+
}
43+
44+
const CHAIN_ID_TO_CIRCLE_DOMAIN = {
45+
...MAINNET_CHAIN_ID_TO_CIRCLE_DOMAIN,
46+
...TESTNET_CHAIN_ID_TO_CIRCLE_DOMAIN,
47+
}
48+
49+
// USDC MessageSent event for extracting the message from logs (v1 API)
50+
const USDC_EVENT = EventFragment.from('MessageSent(bytes message)')
51+
52+
// Type definitions for Circle API responses
53+
interface CircleDecodedMessageBody {
54+
burnToken: string
55+
mintRecipient: string
56+
amount: string
57+
messageSender: string
58+
}
59+
60+
interface CircleDecodedMessage {
61+
sourceDomain: string
62+
destinationDomain: string
63+
nonce: string
64+
sender: string
65+
recipient: string
66+
destinationCaller: string
67+
messageBody: string
68+
decodedMessageBody?: CircleDecodedMessageBody
69+
}
70+
71+
interface CircleMessage {
72+
attestation?: string
73+
message: string
74+
eventNonce: string
75+
cctpVersion: number
76+
status: string
77+
decodedMessage?: CircleDecodedMessage
78+
delayReason?: string | null
79+
}
80+
81+
interface CircleApiResponse {
82+
messages: CircleMessage[]
83+
}
84+
85+
export async function getUSDCAttestationStatus(
86+
providers: Providers,
87+
txHash: string,
88+
argv: {
89+
format: Format
90+
wallet?: string
91+
sourceDomainId?: number
92+
apiVersion?: 'v1' | 'v2'
93+
},
94+
) {
95+
const receipt = await providers.getTxReceipt(txHash)
96+
if (!receipt) throw new Error('Transaction not found')
97+
98+
const source = receipt.provider
99+
100+
// Determine the Circle domain ID
101+
let sourceDomainId: number
102+
let chainId: number | undefined
103+
104+
if (argv.sourceDomainId !== undefined) {
105+
// Use the provided source domain ID
106+
sourceDomainId = argv.sourceDomainId
107+
console.log(`Using provided source domain ID: ${sourceDomainId}`)
108+
} else {
109+
// Fall back to automatic detection from chain ID
110+
const network = await source.getNetwork()
111+
chainId = Number(network.chainId)
112+
113+
if (chainId in CHAIN_ID_TO_CIRCLE_DOMAIN) {
114+
sourceDomainId = CHAIN_ID_TO_CIRCLE_DOMAIN[chainId]
115+
} else {
116+
throw new Error(
117+
`Unsupported chain ID for Circle CCTP: ${chainId}. Please check the supported networks at https://developers.circle.com/cctp/cctp-supported-blockchains or provide --source-domain-id manually`,
118+
)
119+
}
120+
}
121+
122+
const apiVersion = argv.apiVersion || 'v2'
123+
try {
124+
let data: CircleApiResponse
125+
126+
if (apiVersion === 'v2') {
127+
data = await getUsdcAttestationV2(sourceDomainId, txHash)
128+
} else {
129+
if (chainId === undefined) {
130+
const network = await source.getNetwork()
131+
chainId = Number(network.chainId)
132+
}
133+
134+
data = await fetchUsdcAttestationV1(receipt, chainId)
135+
}
136+
137+
switch (argv.format) {
138+
case Format.log:
139+
console.log(
140+
'attestation_status =',
141+
withDateTimestamp({
142+
txHash,
143+
sourceDomainId,
144+
chainId,
145+
messages: data.messages,
146+
timestamp: Date.now() / 1000, // Current timestamp in seconds
147+
}),
148+
)
149+
break
150+
151+
case Format.pretty:
152+
console.log('\n=== Circle CCTP Attestation Status ===')
153+
console.log(`API Version: ${apiVersion}`)
154+
if (apiVersion === 'v2') {
155+
console.log(
156+
`Fetching from: https://iris-api.circle.com/v2/messages/${sourceDomainId}?transactionHash=${txHash}`,
157+
)
158+
} else {
159+
console.log(`Using v1 API with message extraction from transaction logs`)
160+
}
161+
console.log(`Transaction Hash: ${txHash}`)
162+
if (chainId !== undefined) {
163+
console.log(`Network Chain ID: ${chainId}`)
164+
}
165+
console.log(`Circle Domain ID: ${sourceDomainId}`)
166+
167+
if (!data.messages || data.messages.length === 0) {
168+
console.log('❌ No messages found for this transaction.')
169+
break
170+
}
171+
172+
// Create table data for each message
173+
data.messages.forEach((message: CircleMessage, index: number) => {
174+
console.log(`\n📄 Message ${index + 1}:`)
175+
176+
// Basic message info table
177+
const messageInfo = {
178+
Status: message.status,
179+
'Event Nonce': message.eventNonce,
180+
'CCTP Version': message.cctpVersion,
181+
'Delay Reason': message.delayReason || 'None',
182+
}
183+
console.table(messageInfo)
184+
185+
// Decoded message table
186+
if (message.decodedMessage) {
187+
console.log('\n🔍 Decoded Message:')
188+
const decodedInfo = {
189+
'Source Domain': message.decodedMessage.sourceDomain,
190+
'Destination Domain': message.decodedMessage.destinationDomain,
191+
Sender: message.decodedMessage.sender,
192+
Recipient: message.decodedMessage.recipient,
193+
'Destination Caller': message.decodedMessage.destinationCaller,
194+
}
195+
console.table(decodedInfo)
196+
}
197+
198+
// Token transfer details table
199+
if (message.decodedMessage?.decodedMessageBody) {
200+
console.log('\n💰 Token Transfer Details:')
201+
const body = message.decodedMessage.decodedMessageBody
202+
const tokenInfo = {
203+
'Burn Token': body.burnToken,
204+
'Mint Recipient': body.mintRecipient,
205+
Amount: body.amount,
206+
'Message Sender': body.messageSender,
207+
}
208+
console.table(tokenInfo)
209+
}
210+
211+
// Attestation info
212+
if (message.attestation) {
213+
console.log('\n🔐 Attestation:')
214+
console.log(`${message.attestation}`)
215+
}
216+
})
217+
break
218+
219+
case Format.json:
220+
console.info(
221+
JSON.stringify(
222+
{
223+
txHash,
224+
sourceDomainId,
225+
chainId,
226+
apiVersion,
227+
...data,
228+
},
229+
bigIntReplacer,
230+
2,
231+
),
232+
)
233+
break
234+
}
235+
} catch (error) {
236+
console.error('Error fetching attestation status:', error)
237+
throw error
238+
}
239+
}
240+
241+
/**
242+
* Fetches USDC attestation using v1 API by extracting message from transaction logs
243+
*/
244+
async function fetchUsdcAttestationV1(
245+
receipt: TransactionReceipt,
246+
chainId: number,
247+
): Promise<CircleApiResponse> {
248+
const isTestnet = chainId in TESTNET_CHAIN_ID_TO_CIRCLE_DOMAIN
249+
250+
// Find USDC MessageSent event in transaction logs
251+
const messageSentLog = receipt.logs.find((log) => log.topics[0] === USDC_EVENT.topicHash)
252+
if (!messageSentLog) {
253+
throw new Error('USDC MessageSent event not found in transaction logs')
254+
}
255+
256+
// Extract the message bytes from the event data
257+
const message = defaultAbiCoder.decode(USDC_EVENT.inputs, messageSentLog.data)[0] as string
258+
const attestation = await getUsdcAttestation(message, isTestnet)
259+
260+
// Format data to match v2 response structure to make outputting a bit easier
261+
return {
262+
messages: [
263+
{
264+
message: message,
265+
attestation: attestation,
266+
status: 'complete',
267+
eventNonce: 'N/A',
268+
cctpVersion: 1,
269+
},
270+
],
271+
}
272+
}

src/commands/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,3 +6,4 @@ export { estimateGas } from './estimate-gas.ts'
66
export { parseBytes } from './parse.ts'
77
export { showLaneConfigs } from './lane.ts'
88
export { showSupportedTokens } from './supported-tokens.ts'
9+
export { getUSDCAttestationStatus } from './get-attestation-status.ts'

src/index.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { hideBin } from 'yargs/helpers'
88
import {
99
Format,
1010
estimateGas,
11+
getUSDCAttestationStatus,
1112
manualExec,
1213
manualExecSenderQueue,
1314
parseBytes,
@@ -428,6 +429,49 @@ async function main() {
428429
.finally(() => providers.destroy())
429430
},
430431
)
432+
.command(
433+
'getUSDCAttestationStatus <tx_hash>',
434+
'Get attestation status for a USDC transfer given the source transaction hash',
435+
(yargs) =>
436+
yargs
437+
.positional('tx_hash', {
438+
type: 'string',
439+
demandOption: true,
440+
describe: 'transaction hash of the USDC transfer',
441+
})
442+
.options({
443+
wallet: {
444+
type: 'string',
445+
describe:
446+
'Encrypted wallet json file path; password will be prompted if not available in USER_KEY_PASSWORD envvar',
447+
},
448+
'source-domain-id': {
449+
type: 'number',
450+
describe:
451+
'Circle CCTP source domain ID (if not provided, will be determined automatically from the transaction network)',
452+
example: '7',
453+
},
454+
'api-version': {
455+
type: 'string',
456+
choices: ['v1', 'v2'],
457+
default: 'v2',
458+
describe: 'Circle CCTP API version to use',
459+
},
460+
})
461+
.check(({ tx_hash }) => validateSupportedTxHash(tx_hash)),
462+
async (argv) => {
463+
const providers = new Providers(argv)
464+
return getUSDCAttestationStatus(providers, argv.tx_hash, {
465+
...argv,
466+
apiVersion: argv.apiVersion as 'v1' | 'v2',
467+
})
468+
.catch((err) => {
469+
process.exitCode = 1
470+
if (!logParsedError(err)) console.error(err)
471+
})
472+
.finally(() => providers.destroy())
473+
},
474+
)
431475
.demandCommand()
432476
.strict()
433477
.help()

src/lib/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ export { fetchCommitReport } from './commits.ts'
22
export { getErrorData, parseWithFragment, recursiveParseError } from './errors.ts'
33
export { calculateManualExecProof, discoverOffRamp, fetchExecutionReceipts } from './execution.ts'
44
export { estimateExecGasForRequest } from './gas.ts'
5-
export { fetchOffchainTokenData } from './offchain.ts'
5+
export { fetchOffchainTokenData, getUsdcAttestation, getUsdcAttestationV2 } from './offchain.ts'
66
export {
77
decodeMessage,
88
fetchAllMessagesInBatch,

0 commit comments

Comments
 (0)