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
24 changes: 16 additions & 8 deletions agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import {
getReserves as contractGetReserves,
getShareId as contractGetShareId,
} from "./lib/contract";
import { bridgeTokenTool } from "./tools/bridge";
import { bridgeTokenTool, TargetChain } from "./tools/bridge";
import {
Horizon,
Keypair,
Expand All @@ -16,6 +16,8 @@ import {
BASE_FEE
} from "@stellar/stellar-sdk";

const { Server } = Horizon;

export interface AgentConfig {
network: "testnet" | "mainnet";
rpcUrl?: string;
Expand Down Expand Up @@ -106,23 +108,29 @@ export class AgentClient {
}

/**
* Bridge tokens from Stellar to EVM compatible chains.
*
* Bridge USDC from Stellar to an EVM-compatible chain.
*
* ⚠️ IMPORTANT: Mainnet bridging requires BOTH:
* 1. AgentClient initialized with allowMainnet: true
* 2. ALLOW_MAINNET_BRIDGE=true in your .env file
*
* This dual-safeguard approach prevents accidental mainnet bridging.
*
*
* Supported target chains: "ethereum" | "polygon" | "arbitrum" | "base"
*
* @example
* await agent.bridge({ amount: "100", toAddress: "0x...", targetChain: "polygon" });
*
* @param params Bridge parameters
* @returns Bridge transaction result with status, hash, and network
* @returns Bridge transaction result with status, hash, network, and targetChain
*/
async bridge(params: {
amount: string;
toAddress: string;
targetChain?: TargetChain;
}) {
return await bridgeTokenTool.func({
...params,
amount: params.amount,
toAddress: params.toAddress,
targetChain: params.targetChain ?? "ethereum",
fromNetwork:
this.network === "mainnet"
? "stellar-mainnet"
Expand Down
98 changes: 70 additions & 28 deletions tests/unit/tools/bridge.test.ts
Original file line number Diff line number Diff line change
@@ -1,54 +1,78 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { describe, it, expect } from 'vitest';
import { Networks } from '@stellar/stellar-sdk';
import { ChainSymbol } from '@allbridge/bridge-core-sdk';

type StellarNetwork = "stellar-testnet" | "stellar-mainnet";
type TargetChain = "ethereum" | "polygon" | "arbitrum" | "base";

describe('Bridge Tool - Network Configuration', () => {
const STELLAR_NETWORK_CONFIG: Record<StellarNetwork, { networkPassphrase: string }> = {
"stellar-testnet": {
networkPassphrase: Networks.TESTNET,
},
"stellar-mainnet": {
networkPassphrase: Networks.PUBLIC,
},
};
const TARGET_CHAIN_MAP: Record<TargetChain, ChainSymbol> = {

@cubic-dev-ai cubic-dev-ai Bot Mar 29, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Tests duplicate the production TARGET_CHAIN_MAP locally, so assertions validate the test copy rather than the actual bridge implementation mapping.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At tests/unit/tools/bridge.test.ts, line 8:

<comment>Tests duplicate the production TARGET_CHAIN_MAP locally, so assertions validate the test copy rather than the actual bridge implementation mapping.</comment>

<file context>
@@ -1,80 +1,120 @@
-      networkPassphrase: Networks.PUBLIC,
-    },
-  };
+const TARGET_CHAIN_MAP: Record<TargetChain, ChainSymbol> = {
+  ethereum: ChainSymbol.ETH,
+  polygon: ChainSymbol.POL,
</file context>
Fix with Cubic

ethereum: ChainSymbol.ETH,
polygon: ChainSymbol.POL,
arbitrum: ChainSymbol.ARB,
base: ChainSymbol.BAS,
};

const STELLAR_NETWORK_CONFIG: Record<StellarNetwork, { networkPassphrase: string }> = {
"stellar-testnet": { networkPassphrase: Networks.TESTNET },
"stellar-mainnet": { networkPassphrase: Networks.PUBLIC },
};

describe('Bridge Tool - Multi-Chain Support', () => {

describe('Target Chain Mapping', () => {
it('should map ethereum to ChainSymbol.ETH', () => {
expect(TARGET_CHAIN_MAP["ethereum"]).toBe(ChainSymbol.ETH);
});

it('should map polygon to ChainSymbol.POL', () => {
expect(TARGET_CHAIN_MAP["polygon"]).toBe(ChainSymbol.POL);
});

it('should map arbitrum to ChainSymbol.ARB', () => {
expect(TARGET_CHAIN_MAP["arbitrum"]).toBe(ChainSymbol.ARB);
});

it('should map base to ChainSymbol.BAS', () => {
expect(TARGET_CHAIN_MAP["base"]).toBe(ChainSymbol.BAS);
});

it('should support all four target chains', () => {
const chains: TargetChain[] = ["ethereum", "polygon", "arbitrum", "base"];
chains.forEach((chain) => {
expect(TARGET_CHAIN_MAP[chain]).toBeDefined();
});
});
});

describe('Type Safety', () => {
it('should have correct network configuration type', () => {
const testNetwork: StellarNetwork = "stellar-testnet";
expect(testNetwork).toBe("stellar-testnet");
it('should accept valid TargetChain values', () => {
const validChains: TargetChain[] = ["ethereum", "polygon", "arbitrum", "base"];
expect(validChains).toHaveLength(4);
expect(validChains).toContain("polygon");
expect(validChains).toContain("arbitrum");
expect(validChains).toContain("base");
});

it('should validate network options', () => {
const validNetworks: StellarNetwork[] = ["stellar-testnet", "stellar-mainnet"];
expect(validNetworks).toContain("stellar-testnet");
expect(validNetworks).toContain("stellar-mainnet");
expect(validNetworks).toHaveLength(2);
it('should default to ethereum when targetChain is not specified', () => {
const defaultChain: TargetChain = "ethereum";
expect(TARGET_CHAIN_MAP[defaultChain]).toBe(ChainSymbol.ETH);
});
});

describe('Network Passphrases', () => {
describe('Network Configuration', () => {
it('should have correct testnet passphrase', () => {
expect(STELLAR_NETWORK_CONFIG["stellar-testnet"].networkPassphrase).toBe(Networks.TESTNET);
expect(STELLAR_NETWORK_CONFIG["stellar-testnet"].networkPassphrase).toBe("Test SDF Network ; September 2015");
});

it('should have correct mainnet passphrase', () => {
expect(STELLAR_NETWORK_CONFIG["stellar-mainnet"].networkPassphrase).toBe(Networks.PUBLIC);
expect(STELLAR_NETWORK_CONFIG["stellar-mainnet"].networkPassphrase).toBe("Public Global Stellar Network ; September 2015");
});

it('should have passphrase for both networks', () => {
expect(STELLAR_NETWORK_CONFIG["stellar-testnet"]).toBeDefined();
expect(STELLAR_NETWORK_CONFIG["stellar-mainnet"]).toBeDefined();
});
});

describe('Mainnet Safeguards', () => {
it('should block mainnet when ALLOW_MAINNET_BRIDGE is not set', () => {
const fromNetwork: StellarNetwork = "stellar-mainnet";
const allowMainnetBridge = undefined;

const shouldBlock = fromNetwork === "stellar-mainnet" && allowMainnetBridge !== "true";
expect(shouldBlock).toBe(true);
});
Expand All @@ -64,7 +88,6 @@ describe('Bridge Tool - Network Configuration', () => {
it('should allow mainnet when ALLOW_MAINNET_BRIDGE is true', () => {
const fromNetwork: StellarNetwork = "stellar-mainnet";
const allowMainnetBridge = "true";

const shouldBlock = fromNetwork === "stellar-mainnet" && allowMainnetBridge !== "true";
expect(shouldBlock).toBe(false);
});
Expand All @@ -76,5 +99,24 @@ describe('Bridge Tool - Network Configuration', () => {
const shouldBlock = fromNetwork === "stellar-mainnet" && allowMainnetBridge !== "true";
expect(shouldBlock).toBe(false);
});

it('should apply mainnet safeguard for all target chains', () => {
const chains: TargetChain[] = ["ethereum", "polygon", "arbitrum", "base"];
chains.forEach((chain) => {
const fromNetwork: StellarNetwork = "stellar-mainnet";
const allowMainnetBridge: string | undefined = undefined;
const shouldBlock = fromNetwork === "stellar-mainnet" && allowMainnetBridge !== "true";
expect(shouldBlock).toBe(true);
});
});
});

describe('Chain Symbol Values', () => {
it('should have correct string values for chain symbols', () => {
expect(ChainSymbol.ETH).toBe("ETH");
expect(ChainSymbol.POL).toBe("POL");
expect(ChainSymbol.ARB).toBe("ARB");
expect(ChainSymbol.BAS).toBe("BAS");
});
});
});
77 changes: 46 additions & 31 deletions tools/bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,6 @@ import {
Keypair,
Keypair as StellarKeypair,
rpc,
TransactionBuilder as StellarTransactionBuilder,
TransactionBuilder,
Networks
} from "@stellar/stellar-sdk";
import { ensure } from "../utils/utils";
Expand All @@ -25,8 +23,22 @@ dotenv.config({ path: ".env" });

const fromAddress = process.env.STELLAR_PUBLIC_KEY as string;
const privateKey = process.env.STELLAR_PRIVATE_KEY as string;

type StellarNetwork = "stellar-testnet" | "stellar-mainnet";

/**
* Supported target EVM chains for bridging from Stellar.
* Maps user-facing chain names to Allbridge ChainSymbol values.
*/
export type TargetChain = "ethereum" | "polygon" | "arbitrum" | "base";

const TARGET_CHAIN_MAP: Record<TargetChain, ChainSymbol> = {
ethereum: ChainSymbol.ETH,
polygon: ChainSymbol.POL,
arbitrum: ChainSymbol.ARB,
base: ChainSymbol.BAS,
};

const STELLAR_NETWORK_CONFIG: Record<StellarNetwork, { networkPassphrase: string }> = {
"stellar-testnet": {
networkPassphrase: Networks.TESTNET,
Expand All @@ -39,25 +51,32 @@ const STELLAR_NETWORK_CONFIG: Record<StellarNetwork, { networkPassphrase: string
export const bridgeTokenTool = new DynamicStructuredTool({
name: "bridge_token",
description:
"Bridge token from Stellar chain to EVM compatible chains. Requires amount and toAddress as string",
"Bridge USDC from Stellar to an EVM-compatible chain (Ethereum, Polygon, Arbitrum, or Base). " +
"Requires amount, toAddress, and optionally targetChain (defaults to ethereum).",

schema: z.object({
amount: z.string().describe("The amount of tokens to bridge"),
toAddress: z.string().describe("The destination address"),
toAddress: z.string().describe("The destination EVM address"),
fromNetwork: z
.enum(["stellar-testnet", "stellar-mainnet"])
.default("stellar-testnet")
.describe("Source Stellar network"),
targetChain: z
.enum(["ethereum", "polygon", "arbitrum", "base"])
.default("ethereum")
.describe("Destination EVM chain: ethereum | polygon | arbitrum | base"),
}),

func: async ({
amount,
toAddress,
fromNetwork,
targetChain,
}: {
amount: string;
toAddress: string;
fromNetwork: StellarNetwork;
targetChain: TargetChain;
}) => {
// Mainnet safeguard - additional layer beyond AgentClient
if (
Expand All @@ -69,6 +88,8 @@ export const bridgeTokenTool = new DynamicStructuredTool({
);
}

const destinationChainSymbol = TARGET_CHAIN_MAP[targetChain];

const sdk = new AllbridgeCoreSdk({
...nodeRpcUrlsDefault,
SRB: `${process.env.SRB_PROVIDER_URL}`,
Expand All @@ -81,10 +102,13 @@ export const bridgeTokenTool = new DynamicStructuredTool({
(t) => t.symbol === "USDC"
)
);

const destinationChainDetails = chainDetailsMap[destinationChainSymbol];
if (!destinationChainDetails) {
throw new Error(`Chain not supported by Allbridge: ${targetChain}`);
}
const destinationToken = ensure(
chainDetailsMap[ChainSymbol.ETH].tokens.find(
(t) => t.symbol === "USDC"
)
destinationChainDetails.tokens.find((t) => t.symbol === "USDC")
);

const sendParams = {
Expand All @@ -99,11 +123,8 @@ export const bridgeTokenTool = new DynamicStructuredTool({
gasFeePaymentMethod: FeePaymentMethod.WITH_STABLECOIN,
};

const xdrTx = (await sdk.bridge.rawTxBuilder.send(
sendParams
)) as string;
const xdrTx = (await sdk.bridge.rawTxBuilder.send(sendParams)) as string;

// Use unified transaction builder for XDR-based bridge operations
const srbKeypair = Keypair.fromSecret(privateKey);
const transaction = buildTransactionFromXDR(
"bridge",
Expand Down Expand Up @@ -135,10 +156,7 @@ export const bridgeTokenTool = new DynamicStructuredTool({
sentRestoreXdrTx.hash
);

// Handle FAILED restore explicitly
if (
confirmRestoreXdrTx.status === rpc.Api.GetTransactionStatus.FAILED
) {
if (confirmRestoreXdrTx.status === rpc.Api.GetTransactionStatus.FAILED) {
throw new Error(
`Restore transaction failed. Hash: ${sentRestoreXdrTx.hash}`
);
Expand All @@ -151,14 +169,12 @@ export const bridgeTokenTool = new DynamicStructuredTool({
status: "pending_restore",
hash: sentRestoreXdrTx.hash,
network: fromNetwork,
targetChain,
};
}

// Get new tx with updated sequences
const xdrTx2 = (await sdk.bridge.rawTxBuilder.send(
sendParams
)) as string;

// Rebuild tx with updated sequence numbers after restore
const xdrTx2 = (await sdk.bridge.rawTxBuilder.send(sendParams)) as string;
const transaction2 = buildTransactionFromXDR(
"bridge",
xdrTx2,
Expand All @@ -176,14 +192,15 @@ export const bridgeTokenTool = new DynamicStructuredTool({
status: "pending",
hash: sent.hash,
network: fromNetwork,
targetChain,
};
}

if (confirm.status === rpc.Api.GetTransactionStatus.FAILED) {
throw new Error(`Transaction failed. Hash: ${sent.hash}`);
}

// TrustLine check and setup for destinationToken if it is SRB
// TrustLine check and setup for source token on Stellar side
const destinationTokenSBR = sourceToken;

const balanceLine = await sdk.utils.srb.getBalanceLine(
Expand All @@ -193,18 +210,14 @@ export const bridgeTokenTool = new DynamicStructuredTool({

const notEnoughBalanceLine =
!balanceLine ||
Big(balanceLine.balance)
.add(amount)
.gt(Big(balanceLine.limit));
Big(balanceLine.balance).add(amount).gt(Big(balanceLine.limit));

if (notEnoughBalanceLine) {
const xdrTx =
await sdk.utils.srb.buildChangeTrustLineXdrTx({
sender: fromAddress,
tokenAddress: destinationTokenSBR.tokenAddress,
});
const xdrTx = await sdk.utils.srb.buildChangeTrustLineXdrTx({
sender: fromAddress,
tokenAddress: destinationTokenSBR.tokenAddress,
});

// Use unified transaction builder for XDR-based bridge TrustLine operation
const keypair = StellarKeypair.fromSecret(privateKey);
const trustTx = buildTransactionFromXDR(
"bridge",
Expand All @@ -222,15 +235,17 @@ export const bridgeTokenTool = new DynamicStructuredTool({
status: "trustline_submitted",
hash: submit.hash,
network: fromNetwork,
targetChain,
};
}

return {
status: "confirmed",
hash: sent.hash,
network: fromNetwork,
targetChain,
asset: sourceToken.symbol,
amount,
};
},
});
});
Loading