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
35 changes: 35 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
name: CI

on:
push:
branches: [main]
pull_request:
branches: [main]

jobs:
build:
name: typecheck · lint · test · build
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- name: Use Node.js 20
uses: actions/setup-node@v4
with:
node-version: 20
cache: npm

- name: Install dependencies
run: npm ci

- name: Typecheck
run: npm run typecheck

- name: Lint (Biome)
run: npm run lint

- name: Test (Vitest)
run: npm test

- name: Build
run: npm run build
908 changes: 755 additions & 153 deletions package-lock.json

Large diffs are not rendered by default.

6 changes: 1 addition & 5 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,11 +26,7 @@
"require": "./dist/index.cjs"
}
},
"files": [
"dist",
"README.md",
"LICENSE"
],
"files": ["dist", "README.md", "LICENSE"],
"keywords": [
"hedera",
"layerzero",
Expand Down
21 changes: 9 additions & 12 deletions src/tools/get-message-fee.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,9 @@ const GetMessageFeeSchema = z.object({
payInLzToken: z
.boolean()
.default(false)
.describe("Whether to pay the LayerZero fee in ZRO token instead of native gas. Default: false"),
.describe(
"Whether to pay the LayerZero fee in ZRO token instead of native gas. Default: false"
),
});

type GetMessageFeeInput = z.infer<typeof GetMessageFeeSchema>;
Expand Down Expand Up @@ -76,9 +78,10 @@ export class GetMessageFeeTool extends BaseTool<GetMessageFeeInput, GetMessageFe
const provider = getProvider(config.rpcUrl);
const endpoint = new ethers.Contract(config.endpointAddress, ENDPOINT_V2_ABI, provider);

const receiver = args.receiver.startsWith("0x") && args.receiver.length === 66
? args.receiver
: addressToBytes32(args.receiver);
const receiver =
args.receiver.startsWith("0x") && args.receiver.length === 66
? args.receiver
: addressToBytes32(args.receiver);

const message = args.message.startsWith("0x")
? args.message
Expand Down Expand Up @@ -116,16 +119,10 @@ export class GetMessageFeeTool extends BaseTool<GetMessageFeeInput, GetMessageFe
override async shouldSecondaryAction(
coreResult: GetMessageFeeResult | GetMessageFeeError
): Promise<boolean> {
return (
typeof coreResult === "object" && coreResult !== null && "transaction" in coreResult
);
return typeof coreResult === "object" && coreResult !== null && "transaction" in coreResult;
}

async secondaryAction(
payload: never,
_client: Client,
_context: Context
): Promise<never> {
async secondaryAction(payload: never, _client: Client, _context: Context): Promise<never> {
return payload;
}
}
Expand Down
14 changes: 4 additions & 10 deletions src/tools/get-message-status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ export class GetMessageStatusTool extends BaseTool<GetMessageStatusInput, GetMes

const trackingUrl = args.srcTxHash
? `https://layerzeroscan.com/tx/${args.srcTxHash}`
: `https://layerzeroscan.com/`;
: "https://layerzeroscan.com/";

const res = await fetchWithRetry(apiUrl);

Expand All @@ -126,7 +126,7 @@ export class GetMessageStatusTool extends BaseTool<GetMessageStatusInput, GetMes

// API returns { messages: [...] } for list queries, or a single object for GUID lookup
// biome-ignore lint/suspicious/noExplicitAny: external API response
const messages: any[] = Array.isArray(data) ? data : data.messages ?? [data];
const messages: any[] = Array.isArray(data) ? data : (data.messages ?? [data]);

if (messages.length === 0) {
return { success: true, status: "UNKNOWN", message: null, trackingUrl };
Expand Down Expand Up @@ -165,16 +165,10 @@ export class GetMessageStatusTool extends BaseTool<GetMessageStatusInput, GetMes
override async shouldSecondaryAction(
coreResult: GetMessageStatusResult | GetMessageStatusError
): Promise<boolean> {
return (
typeof coreResult === "object" && coreResult !== null && "transaction" in coreResult
);
return typeof coreResult === "object" && coreResult !== null && "transaction" in coreResult;
}

async secondaryAction(
payload: never,
_client: Client,
_context: Context
): Promise<never> {
async secondaryAction(payload: never, _client: Client, _context: Context): Promise<never> {
return payload;
}
}
Expand Down
14 changes: 5 additions & 9 deletions src/tools/get-supported-chains.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@ const GetSupportedChainsSchema = z.object({
filter: z
.string()
.optional()
.describe("Optional name filter — returns only chains whose name contains this string (case-insensitive)"),
.describe(
"Optional name filter — returns only chains whose name contains this string (case-insensitive)"
),
network: z
.enum(["mainnet", "testnet"])
.optional()
Expand Down Expand Up @@ -91,16 +93,10 @@ export class GetSupportedChainsTool extends BaseTool<
override async shouldSecondaryAction(
coreResult: GetSupportedChainsResult | GetSupportedChainsError
): Promise<boolean> {
return (
typeof coreResult === "object" && coreResult !== null && "transaction" in coreResult
);
return typeof coreResult === "object" && coreResult !== null && "transaction" in coreResult;
}

async secondaryAction(
payload: never,
_client: Client,
_context: Context
): Promise<never> {
async secondaryAction(payload: never, _client: Client, _context: Context): Promise<never> {
return payload;
}
}
Expand Down
24 changes: 11 additions & 13 deletions src/tools/send-message.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,7 @@ const SendMessageSchema = z.object({
.int()
.positive()
.describe("LayerZero endpoint ID of the destination chain (e.g. 30101 for Ethereum mainnet)"),
receiver: z
.string()
.describe("Receiver address on the destination chain — EVM 0x address"),
receiver: z.string().describe("Receiver address on the destination chain — EVM 0x address"),
message: z
.string()
.describe("Message payload as a hex string (e.g. '0x68656c6c6f') or UTF-8 string"),
Expand Down Expand Up @@ -127,9 +125,10 @@ export class SendMessageTool extends BaseTool<SendMessageInput, SendMessageInput
let nativeFee = args.nativeFee;
if (!nativeFee) {
const endpoint = new ethers.Contract(config.endpointAddress, ENDPOINT_V2_ABI, provider);
const receiver = args.receiver.startsWith("0x") && args.receiver.length === 66
? args.receiver
: addressToBytes32(args.receiver);
const receiver =
args.receiver.startsWith("0x") && args.receiver.length === 66
? args.receiver
: addressToBytes32(args.receiver);

const fee = await endpoint.quote(
{
Expand Down Expand Up @@ -181,12 +180,9 @@ export class SendMessageTool extends BaseTool<SendMessageInput, SendMessageInput
const { transaction, extras } = payload;
const oapp = new ethers.Contract(transaction.oappAddress, OAPP_SEND_ABI, transaction.signer);

const tx = await oapp.send(
transaction.dstEid,
transaction.message,
transaction.options,
{ value: BigInt(transaction.nativeFee) }
);
const tx = await oapp.send(transaction.dstEid, transaction.message, transaction.options, {
value: BigInt(transaction.nativeFee),
});

const receipt = await tx.wait();

Expand All @@ -195,7 +191,9 @@ export class SendMessageTool extends BaseTool<SendMessageInput, SendMessageInput
let nonce: number | undefined;
for (const log of receipt?.logs ?? []) {
// PacketSent event topic: keccak256("PacketSent(bytes,bytes,address)")
if (log.topics?.[0] === "0x1ab700d4ced0c005b164c0f789fd09fcb90cf7e32c56bc9d5ab3d85f3710fed7") {
if (
log.topics?.[0] === "0x1ab700d4ced0c005b164c0f789fd09fcb90cf7e32c56bc9d5ab3d85f3710fed7"
) {
// GUID is the first 32 bytes of the packet payload — parsing is OApp-specific
// Provide the raw txHash for tracking instead
break;
Expand Down
2 changes: 1 addition & 1 deletion src/utils/options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ export interface LzOptions {
* EndpointV2.quote() and OApp.send(). Uses lzReceive option type 1.
*/
export function buildOptions(opts: LzOptions): string {
let builder = Options.newOptions().addExecutorLzReceiveOption(
const builder = Options.newOptions().addExecutorLzReceiveOption(
Number(opts.gasLimit),
Number(opts.value ?? 0n)
);
Expand Down
8 changes: 2 additions & 6 deletions src/utils/rpc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,7 @@ import { NETWORK_DEFAULTS, readNetwork } from "../networks.js";
* ctxConfig > env vars > network defaults
*/
export function resolveConfig(ctxConfig: LayerZeroConfig | undefined): Required<LayerZeroConfig> {
const network =
ctxConfig?.network ?? readNetwork(process.env.LAYERZERO_NETWORK) ?? "mainnet";
const network = ctxConfig?.network ?? readNetwork(process.env.LAYERZERO_NETWORK) ?? "mainnet";
const defaults = NETWORK_DEFAULTS[network];

return {
Expand All @@ -19,10 +18,7 @@ export function resolveConfig(ctxConfig: LayerZeroConfig | undefined): Required<
defaults.endpointAddress,
endpointId: ctxConfig?.endpointId ?? defaults.endpointId,
rpcUrl: ctxConfig?.rpcUrl ?? process.env.HEDERA_RPC_URL ?? defaults.rpcUrl,
scanApiUrl:
ctxConfig?.scanApiUrl ??
process.env.LAYERZERO_SCAN_API_URL ??
defaults.scanApiUrl,
scanApiUrl: ctxConfig?.scanApiUrl ?? process.env.LAYERZERO_SCAN_API_URL ?? defaults.scanApiUrl,
privateKey: ctxConfig?.privateKey ?? process.env.HEDERA_PRIVATE_KEY ?? "",
};
}
Expand Down
14 changes: 10 additions & 4 deletions tests/get-message-fee.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,9 +85,12 @@ describe("layerzero_get_message_fee", () => {

it("returns error when RPC call throws", async () => {
const { ethers: mockEthers } = await import("ethers");
vi.mocked(mockEthers.Contract).mockImplementationOnce(() => ({
quote: vi.fn().mockRejectedValue(new Error("RPC connection refused")),
}) as never);
vi.mocked(mockEthers.Contract).mockImplementationOnce(
() =>
({
quote: vi.fn().mockRejectedValue(new Error("RPC connection refused")),
}) as never
);

const result = await getMessageFeeTool.coreAction(
{
Expand All @@ -108,7 +111,10 @@ describe("layerzero_get_message_fee", () => {

it("shouldSecondaryAction returns false for fee result", async () => {
const fakeResult = { success: true, nativeFee: "100" };
const should = await getMessageFeeTool.shouldSecondaryAction(fakeResult as never, makeContext());
const should = await getMessageFeeTool.shouldSecondaryAction(
fakeResult as never,
makeContext()
);
expect(should).toBe(false);
});
});
25 changes: 21 additions & 4 deletions tests/get-message-status.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,11 @@ describe("layerzero_get_message_status", () => {
it("returns INFLIGHT status", async () => {
mockFetch({
status: 200,
body: { messages: [{ guid: "0xabc", srcTxHash: "0xtx1", srcEid: 30316, dstEid: 30101, status: "INFLIGHT" }] },
body: {
messages: [
{ guid: "0xabc", srcTxHash: "0xtx1", srcEid: 30316, dstEid: 30101, status: "INFLIGHT" },
],
},
});

const result = await getMessageStatusTool.coreAction(
Expand All @@ -73,7 +77,11 @@ describe("layerzero_get_message_status", () => {
it("returns FAILED status", async () => {
mockFetch({
status: 200,
body: { messages: [{ guid: "0xabc", srcTxHash: "0xtx2", srcEid: 30316, dstEid: 30101, status: "FAILED" }] },
body: {
messages: [
{ guid: "0xabc", srcTxHash: "0xtx2", srcEid: 30316, dstEid: 30101, status: "FAILED" },
],
},
});

const result = await getMessageStatusTool.coreAction(
Expand Down Expand Up @@ -133,7 +141,13 @@ describe("layerzero_get_message_status", () => {
it("accepts guid as lookup key", async () => {
mockFetch({
status: 200,
body: { guid: "0xmyguid", srcTxHash: "0xtx99", srcEid: 30316, dstEid: 30101, status: "DELIVERED" },
body: {
guid: "0xmyguid",
srcTxHash: "0xtx99",
srcEid: 30316,
dstEid: 30101,
status: "DELIVERED",
},
});

const result = await getMessageStatusTool.coreAction(
Expand All @@ -147,7 +161,10 @@ describe("layerzero_get_message_status", () => {

it("shouldSecondaryAction returns false for status result", async () => {
const fakeResult = { success: true, status: "DELIVERED" };
const should = await getMessageStatusTool.shouldSecondaryAction(fakeResult as never, makeContext());
const should = await getMessageStatusTool.shouldSecondaryAction(
fakeResult as never,
makeContext()
);
expect(should).toBe(false);
});
});
11 changes: 9 additions & 2 deletions tests/get-supported-chains.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,11 @@ describe("layerzero_get_supported_chains", () => {
expect(result.sourceEid).toBe(LAYERZERO_MAINNET.endpointId);
expect(result.total).toBe(KNOWN_DESTINATION_CHAINS.length);
expect(result.chains.length).toBe(KNOWN_DESTINATION_CHAINS.length);
expect(result.chains[0]).toMatchObject({ name: expect.any(String), eid: expect.any(Number), network: "mainnet" });
expect(result.chains[0]).toMatchObject({
name: expect.any(String),
eid: expect.any(Number),
network: "mainnet",
});
});

it("returns testnet EIDs when network=testnet", async () => {
Expand Down Expand Up @@ -79,7 +83,10 @@ describe("layerzero_get_supported_chains", () => {

it("shouldSecondaryAction returns false for query result", async () => {
const result = await getSupportedChainsTool.coreAction({}, makeContext(), fakeClient);
const should = await getSupportedChainsTool.shouldSecondaryAction(result as never, makeContext());
const should = await getSupportedChainsTool.shouldSecondaryAction(
result as never,
makeContext()
);
expect(should).toBe(false);
});
});
Loading
Loading