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
93 changes: 91 additions & 2 deletions packages/adapters/rebalance/src/adapters/ccip/ccip.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,65 @@ export class CCIPBridgeAdapter implements BridgeAdapter {
return chainId === SOLANA_CHAIN_ID_NUMBER;
}

/**
* Check message status using Chainlink CCIP Atlas API
* This is a fallback/alternative to the SDK's getExecutionReceipts method
* API docs: https://ccip.chain.link/api/h/atlas/message/{messageId}
*/
private async getMessageStatusFromAtlasAPI(messageId: string): Promise<CCIPTransferStatus | null> {
try {
const apiUrl = `https://ccip.chain.link/api/h/atlas/message/${messageId}`;
this.logger.debug('Checking message status via Chainlink Atlas API', { messageId, apiUrl });

const response = await fetch(apiUrl, {
method: 'GET',
headers: {
Accept: 'application/json',
},
});

if (!response.ok) {
if (response.status === 404) {
// Message not found in Atlas API yet
return null;
}
throw new Error(`Chainlink Atlas API returned ${response.status}: ${response.statusText}`);
}

const data = await response.json();

// Map API state to our status
// state: 0 = Untouched, 1 = InProgress, 2 = Success, 3 = Failure
const state = data.state;
if (state === 2) {
return {
status: 'SUCCESS',
message: 'CCIP transfer completed successfully (via Atlas API)',
messageId: messageId,
};
} else if (state === 3) {
return {
status: 'FAILURE',
message: 'CCIP transfer failed (via Atlas API)',
messageId: messageId,
};
} else {
// state 0 or 1, or other values
return {
status: 'PENDING',
message: `CCIP transfer pending (state: ${state})`,
messageId: messageId,
};
}
} catch (error) {
this.logger.warn('Failed to check message status via Chainlink Atlas API', {
error: jsonifyError(error),
messageId,
});
return null; // Return null to indicate API check failed, fallback to SDK
}
}

private validateCCIPRoute(route: RebalanceRoute): void {
const originChainId = route.origin;
const destinationChainId = route.destination;
Expand Down Expand Up @@ -611,7 +670,7 @@ export class CCIPBridgeAdapter implements BridgeAdapter {
// First, try to extract the message ID from the transaction logs
const requests = await sourceChain.getMessagesInTx(transactionHash);
if (!requests.length) {
this.logger.warn('Could not extract CCIP message ID, will try using transaction hash', {
this.logger.warn('Could not extract CCIP message ID from transaction', {
transactionHash,
originChainId,
});
Expand All @@ -624,6 +683,24 @@ export class CCIPBridgeAdapter implements BridgeAdapter {

const request = requests[0];
const messageId = request.message.messageId;

// Try Atlas API first (faster, more reliable, no rate limits)
this.logger.debug('Trying Atlas API first for message status', { messageId });
const atlasStatus = await this.getMessageStatusFromAtlasAPI(messageId);
if (atlasStatus) {
this.logger.debug('Successfully retrieved status from Atlas API', {
messageId,
status: atlasStatus.status,
});
return atlasStatus;
}

// Atlas API failed or returned null, fall back to SDK method
this.logger.debug('Atlas API unavailable or message not found, falling back to SDK method', {
messageId,
transactionHash,
});

const offRamp = await discoverOffRamp(sourceChain, destinationChain, request.lane.onRamp);
let transferStatus;

Expand Down Expand Up @@ -688,7 +765,7 @@ export class CCIPBridgeAdapter implements BridgeAdapter {
});
continue;
} else {
// Exhausted retries, return early
// Exhausted retries, return pending
this.logger.error('Max retries exceeded for getExecutionReceipts', {
transactionHash,
destinationChainId,
Expand Down Expand Up @@ -759,4 +836,16 @@ export class CCIPBridgeAdapter implements BridgeAdapter {
};
}
}

/**
* Check CCIP message status directly by messageId using Chainlink Atlas API
* This is a lightweight alternative to getTransferStatus that doesn't require transaction hash
*
* @param messageId - The CCIP message ID (0x-prefixed hex string)
* @returns Transfer status or null if message not found
*/
async getTransferStatusByMessageId(messageId: string): Promise<CCIPTransferStatus | null> {
this.logger.debug('Checking CCIP transfer status by messageId via Atlas API', { messageId });
return await this.getMessageStatusFromAtlasAPI(messageId);
}
}
167 changes: 164 additions & 3 deletions packages/adapters/rebalance/test/adapters/ccip/ccip.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,18 @@ jest.mock('bs58', () => {

describe('CCIPBridgeAdapter', () => {
let adapter: TestableCCIPBridgeAdapter;
// Mock global fetch for Atlas API
const mockFetch = jest.fn<typeof fetch>();
let originalFetch: typeof fetch;

beforeAll(() => {
originalFetch = global.fetch;
global.fetch = mockFetch as typeof fetch;
});

afterAll(() => {
global.fetch = originalFetch;
});

beforeEach(() => {
jest.clearAllMocks();
Expand Down Expand Up @@ -447,23 +459,92 @@ describe('CCIPBridgeAdapter', () => {
mockGetExecutionReceipts.mockImplementation(async function* () {
yield mockExecutionReceipt;
});
// Default: Atlas API returns null (not found), so we fall back to SDK
mockFetch.mockResolvedValue({
ok: false,
status: 404,
statusText: 'Not Found',
json: async () => ({}),
} as Response);
});

it('returns SUCCESS from Atlas API when available', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
statusText: 'OK',
json: async () => ({ state: 2, messageId: '0xmsgid' }),
} as Response);

const status = await adapter.getTransferStatus('0xhash', 1, 42161);
expect(status.status).toBe('SUCCESS');
expect(status.messageId).toBe('0xmsgid');
expect(status.message).toContain('via Atlas API');
expect(mockGetExecutionReceipts).not.toHaveBeenCalled(); // SDK should not be called
});

it('returns SUCCESS when execution receipt shows success', async () => {
it('falls back to SDK when Atlas API returns 404', async () => {
// Atlas API returns 404 (default in beforeEach)
const status = await adapter.getTransferStatus('0xhash', 1, 42161);
expect(status.status).toBe('SUCCESS');
expect(status.messageId).toBe('0xmsgid');
expect(mockGetExecutionReceipts).toHaveBeenCalled(); // SDK should be called as fallback
});

it('returns FAILURE when execution receipt shows failure', async () => {
it('returns SUCCESS when execution receipt shows success (SDK fallback)', async () => {
const status = await adapter.getTransferStatus('0xhash', 1, 42161);
expect(status.status).toBe('SUCCESS');
expect(status.messageId).toBe('0xmsgid');
});

it('returns FAILURE from Atlas API when state is 3', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
statusText: 'OK',
json: async () => ({ state: 3, messageId: '0xmsgid' }),
} as Response);

const status = await adapter.getTransferStatus('0xhash', 1, 42161);
expect(status.status).toBe('FAILURE');
expect(status.message).toContain('via Atlas API');
expect(mockGetExecutionReceipts).not.toHaveBeenCalled();
});

it('returns PENDING from Atlas API when state is 1 (InProgress)', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
statusText: 'OK',
json: async () => ({ state: 1, messageId: '0xmsgid' }),
} as Response);

const status = await adapter.getTransferStatus('0xhash', 1, 42161);
expect(status.status).toBe('PENDING');
expect(status.message).toContain('pending (state: 1)');
expect(mockGetExecutionReceipts).not.toHaveBeenCalled();
});

it('returns FAILURE when execution receipt shows failure (SDK fallback)', async () => {
mockGetExecutionReceipts.mockImplementation(async function* () {
yield { receipt: { state: 3 } };
});
const status = await adapter.getTransferStatus('0xhash', 1, 42161);
expect(status.status).toBe('FAILURE');
});

it('returns PENDING when no execution receipts found', async () => {
it('falls back to SDK when Atlas API throws error', async () => {
mockFetch.mockRejectedValueOnce(new Error('Network error'));
mockGetExecutionReceipts.mockImplementation(async function* () {
yield mockExecutionReceipt;
});

const status = await adapter.getTransferStatus('0xhash', 1, 42161);
expect(status.status).toBe('SUCCESS');
expect(mockGetExecutionReceipts).toHaveBeenCalled(); // SDK should be called as fallback
});

it('returns PENDING when no execution receipts found (SDK fallback)', async () => {
mockGetExecutionReceipts.mockImplementation(async function* () {
// Empty generator - no receipts
});
Expand Down Expand Up @@ -586,6 +667,86 @@ describe('CCIPBridgeAdapter', () => {
});
});

describe('getTransferStatusByMessageId', () => {
beforeEach(() => {
jest.clearAllMocks();
});

it('returns SUCCESS when Atlas API returns state 2', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
statusText: 'OK',
json: async () => ({ state: 2, messageId: '0xmsgid' }),
} as Response);

const status = await adapter.getTransferStatusByMessageId('0xmsgid');
expect(status).not.toBeNull();
expect(status?.status).toBe('SUCCESS');
expect(status?.messageId).toBe('0xmsgid');
expect(status?.message).toContain('via Atlas API');
});

it('returns FAILURE when Atlas API returns state 3', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
statusText: 'OK',
json: async () => ({ state: 3, messageId: '0xmsgid' }),
} as Response);

const status = await adapter.getTransferStatusByMessageId('0xmsgid');
expect(status).not.toBeNull();
expect(status?.status).toBe('FAILURE');
expect(status?.message).toContain('via Atlas API');
});

it('returns PENDING when Atlas API returns state 1', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
statusText: 'OK',
json: async () => ({ state: 1, messageId: '0xmsgid' }),
} as Response);

const status = await adapter.getTransferStatusByMessageId('0xmsgid');
expect(status).not.toBeNull();
expect(status?.status).toBe('PENDING');
expect(status?.message).toContain('pending (state: 1)');
});

it('returns null when Atlas API returns 404', async () => {
mockFetch.mockResolvedValueOnce({
ok: false,
status: 404,
statusText: 'Not Found',
json: async () => ({}),
} as Response);

const status = await adapter.getTransferStatusByMessageId('0xmsgid');
expect(status).toBeNull();
});

it('returns null when Atlas API throws error', async () => {
mockFetch.mockRejectedValueOnce(new Error('Network error'));

const status = await adapter.getTransferStatusByMessageId('0xmsgid');
expect(status).toBeNull();
});

it('returns null when Atlas API returns non-200 status', async () => {
mockFetch.mockResolvedValueOnce({
ok: false,
status: 500,
statusText: 'Internal Server Error',
json: async () => ({}),
} as Response);

const status = await adapter.getTransferStatusByMessageId('0xmsgid');
expect(status).toBeNull();
});
});

describe('CCIP constants', () => {
it('has correct Ethereum router address', () => {
expect(CCIP_ROUTER_ADDRESSES[1]).toBe('0x80226fc0Ee2b096224EeAc085Bb9a8cba1146f7D');
Expand Down