Skip to content

Commit 2f4b83b

Browse files
authored
Merge pull request #236 from Yusufolosun/fix/optimize-cross-contract-call-overhead
fix: optimize factory deploy cross-contract call overhead
2 parents 16ed399 + 0c7d30f commit 2f4b83b

5 files changed

Lines changed: 169 additions & 3 deletions

File tree

‎README.md‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1169,6 +1169,17 @@ Builds a Stellar transaction for deploying a Soroban smart contract. Supports tw
11691169
11701170
**Input:**
11711171

1172+
| Parameter | Type | Required | Description |
1173+
|---|---|---|---|
1174+
| `mode` | `string` | Yes | `direct` (built-in deployer) or `factory` (via factory contract) |
1175+
| `source_account` | `string` | Yes | Stellar public key (`G...`) that will pay fees |
1176+
| `wasm_hash` | `string` | No | 64-char hex WASM hash. **Required for direct mode.** |
1177+
| `salt` | `string` | No | 64-char hex salt for deterministic address. Random if omitted. |
1178+
| `factory_contract_id` | `string` | No | Factory contract ID (`C...`). **Required for factory mode.** |
1179+
| `deploy_function` | `string` | No | Factory deploy function name. Default: `deploy` |
1180+
| `deploy_args` | `array` | No | Typed SCVal arguments: `[{ type?: 'symbol'\|'string'\|'u32'\|'i32'\|'u64'\|'i64'\|'u128'\|'i128'\|'bool'\|'address'\|'bytes'\|'void', value: any }]` |
1181+
| `optimize_cross_contract_call` | `boolean` | No | Factory mode only. If `true`, simulates and assembles the transaction to minimize cross-contract resource overhead before returning XDR. Default: `false` |
1182+
| `network` | `string` | No | Override network: `mainnet`, `testnet`, `futurenet`, `custom` |
11721183
| Parameter | Type | Required | Description |
11731184
| --------------------- | -------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
11741185
| `mode` | `string` | Yes | `direct` (built-in deployer) or `factory` (via factory contract) |
@@ -1198,6 +1209,11 @@ Builds a Stellar transaction for deploying a Soroban smart contract. Supports tw
11981209
{
11991210
"mode": "factory",
12001211
"transaction_xdr": "AAAAAgAAAAE...",
1212+
"optimization": {
1213+
"min_resource_fee": "2500",
1214+
"cpu_instructions": "345",
1215+
"memory_bytes": "678"
1216+
},
12011217
"network": "testnet",
12021218
"source_account": "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5"
12031219
}

‎src/index.ts‎

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -299,6 +299,8 @@ class PulsarServer {
299299
'Fetch the ABI/interface spec of a deployed Soroban contract. Returns decoded function signatures, parameter types, and emitted event schemas.',
300300
description:
301301
'Fetch the ABI/interface spec of a deployed Soroban contract. Returns decoded function signatures, parameter types, and emitted event schemas.',
302+
description:
303+
'Fetch the ABI/interface spec of a deployed Soroban contract. Returns decoded function signatures, parameter types, and emitted event schemas.',
302304
inputSchema: {
303305
type: 'object',
304306
description:
@@ -606,6 +608,11 @@ class PulsarServer {
606608
description:
607609
"Arguments for factory deploy function as typed SCVal objects. Each item: { type?: 'symbol'|'string'|'u32'|'i32'|'u64'|'i64'|'u128'|'i128'|'bool'|'address'|'bytes'|'void', value: any }",
608610
},
611+
optimize_cross_contract_call: {
612+
type: 'boolean',
613+
default: false,
614+
description:
615+
'Factory mode only. Simulate and assemble the transaction to minimize cross-contract call resource overhead before returning XDR.',
609616
network: {
610617
address: {
611618
type: 'string',
@@ -1729,7 +1736,10 @@ class PulsarServer {
17291736
case 'fetch_contract_spec': {
17301737
const parsed = fetchContractSpecSchema.safeParse(args);
17311738
if (!parsed.success) {
1732-
throw new PulsarValidationError(`Invalid input for fetch_contract_spec`, parsed.error.format());
1739+
throw new PulsarValidationError(
1740+
`Invalid input for fetch_contract_spec`,
1741+
parsed.error.format()
1742+
);
17331743
}
17341744
const result = await fetchContractSpec(parsed.data);
17351745
return { content: [{ type: "text", text: JSON.stringify(result) }] };

‎src/schemas/tools.ts‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -336,6 +336,7 @@ export type ComputeVestingScheduleInput = z.infer<typeof ComputeVestingScheduleI
336336
* - factory_contract_id: Factory contract ID (required for factory)
337337
* - deploy_function: Factory deploy function name (default: 'deploy')
338338
* - deploy_args: Array of typed SCVal arguments for factory deploy function
339+
* - optimize_cross_contract_call: Optional simulation+assembly for factory mode
339340
* - network: Optional network override
340341
*/
341342
export const DeployContractInputSchema = z.object({
@@ -384,6 +385,12 @@ export const DeployContractInputSchema = z.object({
384385
)
385386
.optional()
386387
.describe('Arguments for factory deploy function as typed SCVal objects'),
388+
optimize_cross_contract_call: z
389+
.boolean()
390+
.optional()
391+
.describe(
392+
'If true in factory mode, simulates and assembles the transaction to minimize cross-contract resource overhead'
393+
),
387394
network: NetworkSchema.optional(),
388395
});
389396

‎src/tools/deploy_contract.ts‎

Lines changed: 76 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,14 @@ import {
66
Address,
77
hash,
88
StrKey,
9+
SorobanRpc,
910
xdr,
1011
nativeToScVal,
1112
Networks,
1213
} from '@stellar/stellar-sdk';
1314

1415
import { getHorizonServer } from '../services/horizon.js';
16+
import { getSorobanServer } from '../services/soroban-rpc.js';
1517
import { config } from '../config.js';
1618
import { DeployContractInputSchema } from '../schemas/tools.js';
1719
import type { McpToolHandler } from '../types.js';
@@ -22,6 +24,11 @@ export interface DeployContractOutput {
2224
mode: 'direct' | 'factory';
2325
transaction_xdr: string;
2426
predicted_contract_id?: string;
27+
optimization?: {
28+
min_resource_fee: string;
29+
cpu_instructions: string;
30+
memory_bytes: string;
31+
};
2532
network: string;
2633
source_account: string;
2734
}
@@ -112,13 +119,17 @@ export const deployContract: McpToolHandler<typeof DeployContractInputSchema> =
112119
logger.debug({ account: sourceAccount, network }, 'Loading source account for deployment');
113120
account = await horizonServer.loadAccount(sourceAccount);
114121
} catch (err: unknown) {
122+
const status = (err as { response?: { status?: number } })?.response?.status;
123+
if (status === 404) {
115124
const error = err as { response?: { status?: number }; message?: string };
116125
if (error.response?.status === 404) {
117126
throw new PulsarNetworkError(
118127
`Source account ${sourceAccount} not found. Fund the account before deploying.`,
119128
{ status: 404, account_id: sourceAccount }
120129
);
121130
}
131+
const message = err instanceof Error ? err.message : String(err);
132+
throw new PulsarNetworkError(`Failed to load source account: ${message}`, {
122133
throw new PulsarNetworkError(`Failed to load source account: ${error.message || String(err)}`, {
123134
originalError: err,
124135
});
@@ -188,18 +199,82 @@ export const deployContract: McpToolHandler<typeof DeployContractInputSchema> =
188199
// ------------------------------------------------------------------
189200
// 3. Build the transaction
190201
// ------------------------------------------------------------------
191-
const tx = new TransactionBuilder(account, {
202+
let tx = new TransactionBuilder(account, {
192203
fee: (100_000).toString(),
193204
networkPassphrase,
194205
})
195206
.addOperation(operation)
196207
.setTimeout(30)
197208
.build();
198209

210+
let optimization:
211+
| {
212+
min_resource_fee: string;
213+
cpu_instructions: string;
214+
memory_bytes: string;
215+
}
216+
| undefined;
217+
218+
if (data.mode === 'factory' && (data.optimize_cross_contract_call ?? false)) {
219+
const sorobanServer = getSorobanServer(network);
220+
221+
let simulation;
222+
try {
223+
simulation = await sorobanServer.simulateTransaction(tx);
224+
} catch (err) {
225+
throw new PulsarNetworkError(
226+
'Failed to simulate factory deployment for cross-contract optimization',
227+
{ originalError: err }
228+
);
229+
}
230+
231+
if (SorobanRpc.Api.isSimulationError(simulation)) {
232+
throw new PulsarNetworkError('Failed to optimize factory deployment: simulation error', {
233+
simulation_error: simulation.error,
234+
});
235+
}
236+
237+
const apiWithRestore = SorobanRpc.Api as unknown as {
238+
isSimulationRestore?: (result: unknown) => boolean;
239+
isSimulationRestoreNeeded?: (result: unknown) => boolean;
240+
};
241+
242+
if (
243+
apiWithRestore.isSimulationRestore?.(simulation) ||
244+
apiWithRestore.isSimulationRestoreNeeded?.(simulation)
245+
) {
246+
throw new PulsarNetworkError(
247+
'Failed to optimize factory deployment: ledger restoration is required',
248+
{ restore_needed: true }
249+
);
250+
}
251+
252+
if (!SorobanRpc.Api.isSimulationSuccess(simulation)) {
253+
throw new PulsarNetworkError(
254+
'Failed to optimize factory deployment: unexpected simulation response'
255+
);
256+
}
257+
258+
try {
259+
tx = await sorobanServer.prepareTransaction(tx);
260+
} catch (err) {
261+
throw new PulsarNetworkError('Failed to assemble optimized factory deployment transaction', {
262+
originalError: err,
263+
});
264+
}
265+
266+
optimization = {
267+
min_resource_fee: simulation.minResourceFee ?? '0',
268+
cpu_instructions: simulation.cost?.cpuInsns ?? '0',
269+
memory_bytes: simulation.cost?.memBytes ?? '0',
270+
};
271+
}
272+
199273
return {
200274
mode: data.mode,
201275
transaction_xdr: tx.toXDR(),
202276
predicted_contract_id: predictedContractId,
277+
optimization,
203278
network,
204279
source_account: sourceAccount,
205280
};

‎tests/unit/deploy_contract.test.ts‎

Lines changed: 59 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,21 @@
11
import { describe, it, expect, vi, beforeEach } from 'vitest';
2-
import { TransactionBuilder, Networks, Keypair } from '@stellar/stellar-sdk';
2+
import { TransactionBuilder, Networks, Keypair, SorobanRpc } from '@stellar/stellar-sdk';
33

44
import { deployContract } from '../../src/tools/deploy_contract.js';
55
import { getHorizonServer } from '../../src/services/horizon.js';
6+
import { getSorobanServer } from '../../src/services/soroban-rpc.js';
67

78
vi.mock('../../src/services/horizon.js', () => ({
89
getHorizonServer: vi.fn(),
910
}));
1011

12+
vi.mock('../../src/services/soroban-rpc.js', () => ({
13+
getSorobanServer: vi.fn(),
14+
}));
15+
1116
describe('deployContract', () => {
1217
let mockServer: any;
18+
let mockSorobanServer: any;
1319

1420
const SOURCE_ACCOUNT = Keypair.random().publicKey();
1521
const WASM_HASH = 'a'.repeat(64);
@@ -20,7 +26,12 @@ describe('deployContract', () => {
2026
mockServer = {
2127
loadAccount: vi.fn(),
2228
};
29+
mockSorobanServer = {
30+
simulateTransaction: vi.fn(),
31+
prepareTransaction: vi.fn(),
32+
};
2333
vi.mocked(getHorizonServer).mockReturnValue(mockServer);
34+
vi.mocked(getSorobanServer).mockReturnValue(mockSorobanServer);
2435
});
2536

2637
function mockAccount(sequence = '123456789') {
@@ -234,6 +245,53 @@ describe('deployContract', () => {
234245
expect(result.transaction_xdr).toBeDefined();
235246
});
236247

248+
it('optimizes factory deployment when optimize_cross_contract_call is true', async () => {
249+
mockAccount();
250+
251+
mockSorobanServer.simulateTransaction.mockResolvedValue({
252+
cost: { cpuInsns: '345', memBytes: '678' },
253+
minResourceFee: '2500',
254+
} as any);
255+
256+
mockSorobanServer.prepareTransaction.mockResolvedValue({
257+
toXDR: () => 'optimized-xdr',
258+
});
259+
260+
vi.spyOn(SorobanRpc.Api, 'isSimulationSuccess').mockReturnValue(true);
261+
vi.spyOn(SorobanRpc.Api, 'isSimulationError').mockReturnValue(false);
262+
263+
const result = (await deployContract({
264+
mode: 'factory',
265+
source_account: SOURCE_ACCOUNT,
266+
factory_contract_id: FACTORY_CONTRACT_ID,
267+
optimize_cross_contract_call: true,
268+
})) as any;
269+
270+
expect(mockSorobanServer.simulateTransaction).toHaveBeenCalledOnce();
271+
expect(mockSorobanServer.prepareTransaction).toHaveBeenCalledOnce();
272+
expect(result.transaction_xdr).toBe('optimized-xdr');
273+
expect(result.optimization).toEqual({
274+
min_resource_fee: '2500',
275+
cpu_instructions: '345',
276+
memory_bytes: '678',
277+
});
278+
});
279+
280+
it('returns a clear error when optimization simulation fails', async () => {
281+
mockAccount();
282+
283+
mockSorobanServer.simulateTransaction.mockRejectedValue(new Error('RPC unavailable'));
284+
285+
await expect(
286+
deployContract({
287+
mode: 'factory',
288+
source_account: SOURCE_ACCOUNT,
289+
factory_contract_id: FACTORY_CONTRACT_ID,
290+
optimize_cross_contract_call: true,
291+
})
292+
).rejects.toThrow('Failed to simulate factory deployment for cross-contract optimization');
293+
});
294+
237295
it('rejects missing factory_contract_id in factory mode', async () => {
238296
mockAccount();
239297

0 commit comments

Comments
 (0)