Skip to content

Commit b81b2b9

Browse files
authored
Merge pull request #281 from raymondidahor-bot/Trustline-helper-tool
Fix create_trustline tool account handling
2 parents 91f61b1 + d208c41 commit b81b2b9

5 files changed

Lines changed: 569 additions & 0 deletions

File tree

‎src/index.ts‎

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ import { sorobanMath } from './tools/soroban_math.js';
5151
import { decodeLedgerEntryTool, decodeLedgerEntrySchema } from './tools/decode_ledger_entry.js';
5252
import { computeVestingSchedule } from './tools/compute_vesting_schedule.js';
5353
import { deployContract } from './tools/deploy_contract.js';
54+
import { createTrustline } from './tools/create_trustline.js';
5455
import { exportAiSchemas } from './tools/export_ai_schemas.js';
5556
import { estimateTokenFees } from './tools/estimate_token_fees.js';
5657
import { getOrderbook } from './tools/get_orderbook.js';
@@ -97,6 +98,7 @@ import {
9798
SorobanMathInputSchema,
9899
ComputeVestingScheduleInputSchema,
99100
DeployContractInputSchema,
101+
CreateTrustlineInputSchema,
100102
ExportAiSchemasInputSchema,
101103
ObserveBridgeEventsInputSchema,
102104
EstimateTokenFeesInputSchema,
@@ -731,6 +733,28 @@ class PulsarServer {
731733
},
732734
},
733735
{
736+
name: 'create_trustline',
737+
description:
738+
'Builds a Stellar transaction for creating a trustline, allowing an account to hold an issued asset. ' +
739+
'Returns the unsigned transaction XDR. A trustline must be created before an account can receive or hold an issued asset (anything other than XLM).',
740+
inputSchema: {
741+
type: 'object',
742+
properties: {
743+
source_account: {
744+
type: 'string',
745+
description: 'The Stellar public key (G...) that will hold the trustline.',
746+
},
747+
asset_code: {
748+
type: 'string',
749+
description: 'Asset code (e.g., USDC). Max 12 characters.',
750+
},
751+
asset_issuer: {
752+
type: 'string',
753+
description: 'Stellar public key (G...) of the asset issuer.',
754+
},
755+
limit: {
756+
type: 'string',
757+
description: 'Optional: Maximum amount of the asset the account can hold (e.g., "1000.50"). Defaults to max allowed.',
734758
name: 'deploy_contract',
735759
description: 'Build deploy transaction.',
736760
inputSchema: {
@@ -1756,6 +1780,11 @@ class PulsarServer {
17561780
description: 'Override the configured network for this call.',
17571781
},
17581782
},
1783+
required: ['source_account', 'asset_code', 'asset_issuer'],
1784+
},
1785+
},
1786+
],
1787+
}));
17591788
required: ['selling_asset_code', 'buying_asset_code'],
17601789
required: ['contract_id', 'base_asset', 'quote_asset'],
17611790
additional_keys: {
@@ -2710,6 +2739,17 @@ class PulsarServer {
27102739
};
27112740
}
27122741

2742+
case 'create_trustline': {
2743+
const parsed = CreateTrustlineInputSchema.safeParse(args);
2744+
if (!parsed.success) {
2745+
throw new PulsarValidationError(`Invalid input for create_trustline`, parsed.error.format());
2746+
}
2747+
const result = await createTrustline(parsed.data);
2748+
return {
2749+
content: [{ type: 'text', text: JSON.stringify(result) }],
2750+
};
2751+
}
2752+
27132753
default:
27142754
throw new McpError(ErrorCode.MethodNotFound, `Tool not found: ${name}`);
27152755
}

‎src/schemas/tools.ts‎

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -500,6 +500,39 @@ export type GetAccountHistoryInput = z.infer<typeof GetAccountHistoryInputSchema
500500
export type DeployContractInput = z.infer<typeof DeployContractInputSchema>;
501501

502502
/**
503+
* Schema for create_trustline tool
504+
*
505+
* Inputs:
506+
* - source_account: Stellar public key that will hold the trustline (required)
507+
* - asset_code: Asset code (e.g., 'USDC') (required)
508+
* - asset_issuer: Stellar public key of the asset issuer (required)
509+
* - limit: Maximum amount of the asset the account can hold (optional, defaults to max)
510+
* - network: Optional network override
511+
*/
512+
export const CreateTrustlineInputSchema = z.object({
513+
source_account: StellarPublicKeySchema.describe(
514+
"The Stellar account that will hold the trustline"
515+
),
516+
asset_code: z
517+
.string()
518+
.min(1, { message: "asset_code cannot be empty" })
519+
.max(12, { message: "asset_code cannot exceed 12 characters" })
520+
.describe("Asset code (e.g., 'USDC')"),
521+
asset_issuer: StellarPublicKeySchema.describe(
522+
"Stellar public key of the asset issuer"
523+
),
524+
limit: z
525+
.string()
526+
.optional()
527+
.describe(
528+
"Maximum amount of the asset the account can hold (e.g., '1000.50'). Defaults to max allowed."
529+
),
530+
network: NetworkSchema.optional(),
531+
});
532+
533+
export type CreateTrustlineInput = z.infer<
534+
typeof CreateTrustlineInputSchema
535+
>;
503536
* Schema for export_ai_schemas tool
504537
*
505538
* Exports comprehensive schema definitions of all Pulsar tools in a format optimized

‎src/tools/create_trustline.ts‎

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
import {
2+
Asset,
3+
Account,
4+
TransactionBuilder,
5+
Operation,
6+
Networks,
7+
} from "@stellar/stellar-sdk";
8+
9+
import { getHorizonServer } from "../services/horizon.js";
10+
import { config } from "../config.js";
11+
import { CreateTrustlineInputSchema } from "../schemas/tools.js";
12+
import type { McpToolHandler } from "../types.js";
13+
import {
14+
PulsarValidationError,
15+
PulsarNetworkError,
16+
} from "../errors.js";
17+
import logger from "../logger.js";
18+
19+
export interface CreateTrustlineOutput {
20+
transaction_xdr: string;
21+
source_account: string;
22+
asset: {
23+
code: string;
24+
issuer: string;
25+
};
26+
limit: string;
27+
network: string;
28+
}
29+
30+
/** Resolve the stellar-base network passphrase. */
31+
function resolveNetworkPassphrase(network: string): string {
32+
switch (network) {
33+
case "mainnet":
34+
return Networks.PUBLIC;
35+
case "futurenet":
36+
return Networks.FUTURENET;
37+
case "testnet":
38+
default:
39+
return Networks.TESTNET;
40+
}
41+
}
42+
43+
/**
44+
* Tool: create_trustline
45+
*
46+
* Builds a Stellar transaction for creating a trustline (allowing an account
47+
* to hold a specific issued asset). Returns the unsigned transaction XDR.
48+
*
49+
* A trustline must be created before an account can receive or hold an
50+
* issued asset (anything other than XLM).
51+
*/
52+
export const createTrustline: McpToolHandler<
53+
typeof CreateTrustlineInputSchema
54+
> = async (input: unknown) => {
55+
const validatedInput = CreateTrustlineInputSchema.safeParse(input);
56+
if (!validatedInput.success) {
57+
throw new PulsarValidationError(
58+
"Invalid input for create_trustline",
59+
validatedInput.error.format()
60+
);
61+
}
62+
63+
const data = validatedInput.data;
64+
const network = data.network ?? config.stellarNetwork;
65+
const networkPassphrase = resolveNetworkPassphrase(network);
66+
const sourceAccount = data.source_account;
67+
68+
// ------------------------------------------------------------------
69+
// 1. Fetch source account from Horizon for sequence number
70+
// ------------------------------------------------------------------
71+
const horizonServer = getHorizonServer(network);
72+
let rawAccount;
73+
try {
74+
logger.debug(
75+
{ account: sourceAccount, network },
76+
"Loading source account for trustline creation"
77+
);
78+
rawAccount = await horizonServer.loadAccount(sourceAccount);
79+
} catch (err: any) {
80+
if (err.response?.status === 404) {
81+
throw new PulsarNetworkError(
82+
`Source account ${sourceAccount} not found. Fund the account before creating a trustline.`,
83+
{ status: 404, account_id: sourceAccount }
84+
);
85+
}
86+
throw new PulsarNetworkError(
87+
`Failed to load source account: ${err.message}`,
88+
{ originalError: err }
89+
);
90+
}
91+
92+
const account =
93+
typeof (rawAccount as any).incrementSequenceNumber === "function" &&
94+
typeof (rawAccount as any).sequenceNumber === "function"
95+
? (rawAccount as unknown as Account)
96+
: new Account(
97+
typeof (rawAccount as any).accountId === "function"
98+
? (rawAccount as any).accountId()
99+
: (rawAccount as any).accountId,
100+
typeof (rawAccount as any).sequenceNumber === "function"
101+
? (rawAccount as any).sequenceNumber()
102+
: (rawAccount as any).sequenceNumber
103+
);
104+
105+
// ------------------------------------------------------------------
106+
// 2. Create the asset object
107+
// ------------------------------------------------------------------
108+
let asset: Asset;
109+
try {
110+
asset = new Asset(data.asset_code, data.asset_issuer);
111+
} catch (err: any) {
112+
throw new PulsarValidationError(
113+
`Invalid asset or issuer: ${err.message}`,
114+
{ asset_code: data.asset_code, asset_issuer: data.asset_issuer }
115+
);
116+
}
117+
118+
// ------------------------------------------------------------------
119+
// 3. Build the changeTrust operation
120+
// ------------------------------------------------------------------
121+
const changeTrustOp = Operation.changeTrust({
122+
asset,
123+
limit: data.limit,
124+
});
125+
126+
logger.debug(
127+
{
128+
sourceAccount: data.source_account,
129+
assetCode: data.asset_code,
130+
assetIssuer: data.asset_issuer,
131+
limit: data.limit || "unlimited",
132+
},
133+
"Building trustline creation transaction"
134+
);
135+
136+
// ------------------------------------------------------------------
137+
// 4. Build the transaction
138+
// ------------------------------------------------------------------
139+
const tx = new TransactionBuilder(account, {
140+
fee: "100",
141+
networkPassphrase,
142+
})
143+
.addOperation(changeTrustOp)
144+
.setTimeout(30)
145+
.build();
146+
147+
return {
148+
transaction_xdr: tx.toXDR(),
149+
source_account: sourceAccount,
150+
asset: {
151+
code: data.asset_code,
152+
issuer: data.asset_issuer,
153+
},
154+
limit: data.limit || "922337203685.4775807", // MAX_INT64 in stroops / 10^7
155+
network,
156+
};
157+
};
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import { describe, it, expect, beforeAll } from "vitest";
2+
import { createTrustline } from "../../src/tools/create_trustline.js";
3+
import {
4+
describeIfIntegration,
5+
fundWithFriendbot,
6+
TEST_ACCOUNT_PUBLIC_KEY,
7+
} from "./setup.js";
8+
9+
/**
10+
* Integration tests for create_trustline tool.
11+
*
12+
* These tests hit the real Stellar Testnet.
13+
* Set RUN_INTEGRATION_TESTS=true to run them.
14+
*/
15+
16+
describeIfIntegration("create_trustline (Integration)", () => {
17+
// Test asset configuration for testnet
18+
const TEST_ASSET_CODE = "TEST";
19+
const TEST_ASSET_ISSUER = "GBV3Y3CRDBHCBK4KZ7Q5MZ7CJFS7K3LYKX3LKF2Q4LHMVZ7MNTB6UQHP";
20+
21+
// Ensure test account is funded before tests
22+
beforeAll(async () => {
23+
try {
24+
await fundWithFriendbot(TEST_ACCOUNT_PUBLIC_KEY);
25+
// Wait a moment for the funding transaction to be processed
26+
await new Promise((resolve) => setTimeout(resolve, 2000));
27+
} catch (error) {
28+
console.log("Setup: Account may already be funded or funding failed:", error);
29+
}
30+
});
31+
32+
it("creates a trustline for a valid asset", async () => {
33+
const result = (await createTrustline({
34+
source_account: TEST_ACCOUNT_PUBLIC_KEY,
35+
asset_code: TEST_ASSET_CODE,
36+
asset_issuer: TEST_ASSET_ISSUER,
37+
network: "testnet",
38+
})) as any;
39+
40+
expect(result.transaction_xdr).toBeTruthy();
41+
expect(result.source_account).toBe(TEST_ACCOUNT_PUBLIC_KEY);
42+
expect(result.asset.code).toBe(TEST_ASSET_CODE);
43+
expect(result.asset.issuer).toBe(TEST_ASSET_ISSUER);
44+
expect(result.network).toBe("testnet");
45+
});
46+
47+
it("creates a trustline with a custom limit", async () => {
48+
const result = (await createTrustline({
49+
source_account: TEST_ACCOUNT_PUBLIC_KEY,
50+
asset_code: TEST_ASSET_CODE,
51+
asset_issuer: TEST_ASSET_ISSUER,
52+
limit: "1000000",
53+
network: "testnet",
54+
})) as any;
55+
56+
expect(result.limit).toBe("1000000");
57+
expect(result.transaction_xdr).toBeTruthy();
58+
});
59+
60+
it("returns a transaction that can be simulated later", async () => {
61+
const result = (await createTrustline({
62+
source_account: TEST_ACCOUNT_PUBLIC_KEY,
63+
asset_code: TEST_ASSET_CODE,
64+
asset_issuer: TEST_ASSET_ISSUER,
65+
network: "testnet",
66+
})) as any;
67+
68+
// The transaction XDR should be a valid base64 string
69+
expect(() => {
70+
Buffer.from(result.transaction_xdr, "base64");
71+
}).not.toThrow();
72+
73+
// Verify the XDR length is reasonable (should be > 300 bytes base64-encoded)
74+
expect(result.transaction_xdr.length).toBeGreaterThan(300);
75+
});
76+
77+
it("fails gracefully with an unfunded account", async () => {
78+
const unfundedAccount = "GCXCXCXCXCXCXCXCXCXCXCXCXCXCXCXCXCXCXCXCXCXCXCXCXCXCXC77LQ";
79+
80+
await expect(
81+
createTrustline({
82+
source_account: unfundedAccount,
83+
asset_code: TEST_ASSET_CODE,
84+
asset_issuer: TEST_ASSET_ISSUER,
85+
network: "testnet",
86+
})
87+
).rejects.toThrow("not found");
88+
});
89+
});

0 commit comments

Comments
 (0)