diff --git a/packages/analyzers/soroban/tokens/cost/__tests__/token-cost-aggregator.spec.ts b/packages/analyzers/soroban/tokens/cost/__tests__/token-cost-aggregator.spec.ts
new file mode 100644
index 00000000..489e6806
--- /dev/null
+++ b/packages/analyzers/soroban/tokens/cost/__tests__/token-cost-aggregator.spec.ts
@@ -0,0 +1,95 @@
+import {
+ aggregateTokenCosts,
+ extractTokenCalls,
+ groupCostsByOperation,
+ groupCostsByAsset,
+} from '../token-cost-aggregator';
+
+describe('Soroban Token Call Cost Aggregator (#873)', () => {
+ const SAMPLE_CONTRACT = `
+ pub fn batch_payout(env: Env, usdc: Address, xlm: Address, to: Address, amount: i128) {
+ let usdc_client = token::Client::new(&env, &usdc);
+ let xlm_client = token::Client::new(&env, &xlm);
+
+ let b1 = usdc_client.balance(&to);
+ usdc_client.transfer(&env.current_contract_address(), &to, &amount);
+ let b2 = usdc_client.balance(&to);
+
+ xlm_client.transfer(&env.current_contract_address(), &to, &amount);
+ xlm_client.approve(&to, &to, &amount, &1000);
+ }
+
+ pub fn loop_transfers(env: Env, token_addr: Address, recipients: Vec
) {
+ let client = token::Client::new(&env, &token_addr);
+ for r in recipients.iter() {
+ client.transfer(&env.current_contract_address(), &r, &100);
+ }
+ }
+ `;
+
+ test('extractTokenCalls identifies token operations and resolves assets', () => {
+ const calls = extractTokenCalls(SAMPLE_CONTRACT);
+ expect(calls.length).toBe(6);
+
+ const methods = calls.map((c) => c.method);
+ expect(methods).toContain('balance');
+ expect(methods).toContain('transfer');
+ expect(methods).toContain('approve');
+
+ const inLoopCall = calls.find((c) => c.fn === 'loop_transfers');
+ expect(inLoopCall?.inLoop).toBe(true);
+ expect(inLoopCall?.resourceCost.cpuInstructions).toBeGreaterThan(120_000);
+ });
+
+ test('groupCostsByOperation aggregates resources and ranks dominant operations', () => {
+ const calls = extractTokenCalls(SAMPLE_CONTRACT);
+ const totalCpu = calls.reduce((s, c) => s + c.resourceCost.cpuInstructions, 0);
+ const groups = groupCostsByOperation(calls, totalCpu);
+
+ expect(groups.length).toBe(3); // transfer, balance, approve
+ expect(groups[0].operation).toBe('transfer');
+ expect(groups[0].count).toBe(3);
+ expect(groups[0].percentageOfTotalCpu).toBeGreaterThan(50);
+ });
+
+ test('groupCostsByAsset groups calls by token asset identifier', () => {
+ const calls = extractTokenCalls(SAMPLE_CONTRACT);
+ const assetGroups = groupCostsByAsset(calls);
+
+ expect(assetGroups.some((g) => g.token === 'usdc')).toBe(true);
+ expect(assetGroups.some((g) => g.token === 'xlm')).toBe(true);
+ expect(assetGroups.some((g) => g.token === 'token_addr')).toBe(true);
+
+ const usdcGroup = assetGroups.find((g) => g.token === 'usdc');
+ expect(usdcGroup?.count).toBe(3); // 2 balances, 1 transfer
+ expect(usdcGroup?.dominantOperation).toBe('balance');
+ });
+
+ test('aggregateTokenCosts produces comprehensive report and recommendations', () => {
+ const report = aggregateTokenCosts(SAMPLE_CONTRACT);
+
+ expect(report.totalCalls).toBe(6);
+ expect(report.dominantOperation).toBe('transfer');
+ expect(report.totalEstimatedCpuInstructions).toBeGreaterThan(0);
+ expect(report.totalEstimatedMemoryBytes).toBeGreaterThan(0);
+ expect(report.totalEstimatedStorageReadBytes).toBeGreaterThan(0);
+
+ // Verify recommendations
+ expect(report.recommendations.some((r) => r.includes('inside loops'))).toBe(true);
+ expect(report.recommendations.some((r) => r.includes('balance queries'))).toBe(true);
+ expect(report.recommendations.some((r) => r.includes('transfers'))).toBe(true);
+ });
+
+ test('returns empty report for contracts without token calls', () => {
+ const report = aggregateTokenCosts(`
+ pub fn add(a: u32, b: u32) -> u32 {
+ a + b
+ }
+ `);
+
+ expect(report.totalCalls).toBe(0);
+ expect(report.dominantOperation).toBeNull();
+ expect(report.dominantAsset).toBeNull();
+ expect(report.recommendations.length).toBe(0);
+ });
+});
diff --git a/packages/analyzers/soroban/tokens/cost/index.ts b/packages/analyzers/soroban/tokens/cost/index.ts
new file mode 100644
index 00000000..81a61f81
--- /dev/null
+++ b/packages/analyzers/soroban/tokens/cost/index.ts
@@ -0,0 +1 @@
+export * from './token-cost-aggregator';
diff --git a/packages/analyzers/soroban/tokens/cost/token-cost-aggregator.ts b/packages/analyzers/soroban/tokens/cost/token-cost-aggregator.ts
new file mode 100644
index 00000000..7caf246d
--- /dev/null
+++ b/packages/analyzers/soroban/tokens/cost/token-cost-aggregator.ts
@@ -0,0 +1,349 @@
+import {
+ maskNonCode,
+ createLineResolver,
+ extractFunctions,
+ receiverBefore,
+ resolveTokenBindings,
+ resolveTokenFromReceiver,
+ blockStackAt,
+ isInLoop,
+} from '../../common/source-utils';
+
+export interface ResourceEstimate {
+ cpuInstructions: number;
+ memoryBytes: number;
+ storageReadBytes: number;
+ storageWriteBytes: number;
+}
+
+export interface TokenCallSite {
+ fn: string;
+ token: string;
+ method: string;
+ line: number;
+ offset: number;
+ inLoop: boolean;
+ resourceCost: ResourceEstimate;
+}
+
+export interface TokenOperationCostGroup {
+ operation: string;
+ count: number;
+ totalCpuInstructions: number;
+ totalMemoryBytes: number;
+ totalStorageReadBytes: number;
+ totalStorageWriteBytes: number;
+ percentageOfTotalCpu: number;
+ lines: number[];
+}
+
+export interface TokenAssetCostGroup {
+ token: string;
+ count: number;
+ totalCpuInstructions: number;
+ totalMemoryBytes: number;
+ totalStorageReadBytes: number;
+ totalStorageWriteBytes: number;
+ dominantOperation: string;
+ operations: string[];
+}
+
+export interface TokenCostReport {
+ callSites: TokenCallSite[];
+ byOperation: TokenOperationCostGroup[];
+ byAsset: TokenAssetCostGroup[];
+ totalCalls: number;
+ totalEstimatedCpuInstructions: number;
+ totalEstimatedMemoryBytes: number;
+ totalEstimatedStorageReadBytes: number;
+ totalEstimatedStorageWriteBytes: number;
+ dominantOperation: string | null;
+ dominantAsset: string | null;
+ recommendations: string[];
+}
+
+/**
+ * Benchmark estimates for standard Soroban token contract methods (SEP-41 / token::Client).
+ */
+export const TOKEN_METHOD_BENCHMARKS: Record = {
+ transfer: {
+ cpuInstructions: 120_000,
+ memoryBytes: 1_200,
+ storageReadBytes: 600,
+ storageWriteBytes: 600,
+ },
+ transfer_from: {
+ cpuInstructions: 150_000,
+ memoryBytes: 1_500,
+ storageReadBytes: 800,
+ storageWriteBytes: 800,
+ },
+ balance: {
+ cpuInstructions: 40_000,
+ memoryBytes: 400,
+ storageReadBytes: 300,
+ storageWriteBytes: 0,
+ },
+ approve: {
+ cpuInstructions: 80_000,
+ memoryBytes: 800,
+ storageReadBytes: 400,
+ storageWriteBytes: 400,
+ },
+ mint: {
+ cpuInstructions: 110_000,
+ memoryBytes: 1_100,
+ storageReadBytes: 500,
+ storageWriteBytes: 500,
+ },
+ burn: {
+ cpuInstructions: 110_000,
+ memoryBytes: 1_100,
+ storageReadBytes: 500,
+ storageWriteBytes: 500,
+ },
+ burn_from: {
+ cpuInstructions: 140_000,
+ memoryBytes: 1_400,
+ storageReadBytes: 700,
+ storageWriteBytes: 700,
+ },
+ clawback: {
+ cpuInstructions: 130_000,
+ memoryBytes: 1_300,
+ storageReadBytes: 600,
+ storageWriteBytes: 600,
+ },
+ allowance: {
+ cpuInstructions: 45_000,
+ memoryBytes: 450,
+ storageReadBytes: 350,
+ storageWriteBytes: 0,
+ },
+ decimals: {
+ cpuInstructions: 25_000,
+ memoryBytes: 200,
+ storageReadBytes: 200,
+ storageWriteBytes: 0,
+ },
+ name: {
+ cpuInstructions: 25_000,
+ memoryBytes: 200,
+ storageReadBytes: 200,
+ storageWriteBytes: 0,
+ },
+ symbol: {
+ cpuInstructions: 25_000,
+ memoryBytes: 200,
+ storageReadBytes: 200,
+ storageWriteBytes: 0,
+ },
+};
+
+const TOKEN_METHOD_NAMES = Object.keys(TOKEN_METHOD_BENCHMARKS);
+const TOKEN_CALL_REGEX = new RegExp(
+ `\\.\\s*(${TOKEN_METHOD_NAMES.join('|')})\\s*\\(`,
+ 'g',
+);
+
+/**
+ * Extract all token contract call sites with resolved receiver tokens and benchmark estimates.
+ */
+export function extractTokenCalls(source: string): TokenCallSite[] {
+ const masked = maskNonCode(source);
+ const lineOf = createLineResolver(source);
+ const bindings = resolveTokenBindings(masked, source);
+ const functions = extractFunctions(masked, source);
+ const callSites: TokenCallSite[] = [];
+
+ let m: RegExpExecArray | null;
+ while ((m = TOKEN_CALL_REGEX.exec(masked)) !== null) {
+ const offset = m.index;
+ const method = m[1];
+ const enclosing = functions.find((f) => offset >= f.bodyStart && offset < f.bodyEnd);
+ if (!enclosing) continue;
+
+ const receiver = receiverBefore(source, offset);
+ const token = resolveTokenFromReceiver(receiver, bindings);
+ const stack = blockStackAt(masked, enclosing.bodyStart, offset);
+ const inLoop = isInLoop(stack);
+
+ const baseCost = TOKEN_METHOD_BENCHMARKS[method] || {
+ cpuInstructions: 50_000,
+ memoryBytes: 500,
+ storageReadBytes: 300,
+ storageWriteBytes: 0,
+ };
+
+ // Loops amplify estimated execution cost (conservative 5x multiplier for unbounded/loop contexts)
+ const loopMultiplier = inLoop ? 5 : 1;
+ const resourceCost: ResourceEstimate = {
+ cpuInstructions: baseCost.cpuInstructions * loopMultiplier,
+ memoryBytes: baseCost.memoryBytes * loopMultiplier,
+ storageReadBytes: baseCost.storageReadBytes * loopMultiplier,
+ storageWriteBytes: baseCost.storageWriteBytes * loopMultiplier,
+ };
+
+ callSites.push({
+ fn: enclosing.name,
+ token,
+ method,
+ line: lineOf(offset),
+ offset,
+ inLoop,
+ resourceCost,
+ });
+ }
+
+ return callSites.sort((a, b) => a.offset - b.offset);
+}
+
+/**
+ * Group token call costs by method operation.
+ */
+export function groupCostsByOperation(
+ callSites: TokenCallSite[],
+ totalCpu: number,
+): TokenOperationCostGroup[] {
+ const groups = new Map();
+
+ for (const site of callSites) {
+ const list = groups.get(site.method) || [];
+ list.push(site);
+ groups.set(site.method, list);
+ }
+
+ const result: TokenOperationCostGroup[] = [];
+
+ for (const [operation, sites] of groups.entries()) {
+ const opCpu = sites.reduce((sum, s) => sum + s.resourceCost.cpuInstructions, 0);
+ const opMem = sites.reduce((sum, s) => sum + s.resourceCost.memoryBytes, 0);
+ const opRead = sites.reduce((sum, s) => sum + s.resourceCost.storageReadBytes, 0);
+ const opWrite = sites.reduce((sum, s) => sum + s.resourceCost.storageWriteBytes, 0);
+
+ result.push({
+ operation,
+ count: sites.length,
+ totalCpuInstructions: opCpu,
+ totalMemoryBytes: opMem,
+ totalStorageReadBytes: opRead,
+ totalStorageWriteBytes: opWrite,
+ percentageOfTotalCpu: totalCpu > 0 ? Math.round((opCpu / totalCpu) * 100) : 0,
+ lines: sites.map((s) => s.line),
+ });
+ }
+
+ return result.sort((a, b) => b.totalCpuInstructions - a.totalCpuInstructions);
+}
+
+/**
+ * Group token call costs by target asset token.
+ */
+export function groupCostsByAsset(callSites: TokenCallSite[]): TokenAssetCostGroup[] {
+ const groups = new Map();
+
+ for (const site of callSites) {
+ const list = groups.get(site.token) || [];
+ list.push(site);
+ groups.set(site.token, list);
+ }
+
+ const result: TokenAssetCostGroup[] = [];
+
+ for (const [token, sites] of groups.entries()) {
+ const assetCpu = sites.reduce((sum, s) => sum + s.resourceCost.cpuInstructions, 0);
+ const assetMem = sites.reduce((sum, s) => sum + s.resourceCost.memoryBytes, 0);
+ const assetRead = sites.reduce((sum, s) => sum + s.resourceCost.storageReadBytes, 0);
+ const assetWrite = sites.reduce((sum, s) => sum + s.resourceCost.storageWriteBytes, 0);
+
+ // Identify dominant operation for this asset
+ const opCounts = new Map();
+ for (const s of sites) {
+ opCounts.set(s.method, (opCounts.get(s.method) || 0) + 1);
+ }
+ const dominantOperation = Array.from(opCounts.entries()).sort((a, b) => b[1] - a[1])[0]?.[0] || 'none';
+
+ result.push({
+ token,
+ count: sites.length,
+ totalCpuInstructions: assetCpu,
+ totalMemoryBytes: assetMem,
+ totalStorageReadBytes: assetRead,
+ totalStorageWriteBytes: assetWrite,
+ dominantOperation,
+ operations: Array.from(new Set(sites.map((s) => s.method))),
+ });
+ }
+
+ return result.sort((a, b) => b.totalCpuInstructions - a.totalCpuInstructions);
+}
+
+/**
+ * Aggregate token contract call costs and produce a comprehensive resource impact report.
+ */
+export function aggregateTokenCosts(source: string): TokenCostReport {
+ const callSites = extractTokenCalls(source);
+
+ const totalEstimatedCpuInstructions = callSites.reduce(
+ (sum, s) => sum + s.resourceCost.cpuInstructions,
+ 0,
+ );
+ const totalEstimatedMemoryBytes = callSites.reduce(
+ (sum, s) => sum + s.resourceCost.memoryBytes,
+ 0,
+ );
+ const totalEstimatedStorageReadBytes = callSites.reduce(
+ (sum, s) => sum + s.resourceCost.storageReadBytes,
+ 0,
+ );
+ const totalEstimatedStorageWriteBytes = callSites.reduce(
+ (sum, s) => sum + s.resourceCost.storageWriteBytes,
+ 0,
+ );
+
+ const byOperation = groupCostsByOperation(callSites, totalEstimatedCpuInstructions);
+ const byAsset = groupCostsByAsset(callSites);
+
+ const dominantOperation = byOperation[0]?.operation || null;
+ const dominantAsset = byAsset[0]?.token || null;
+
+ const recommendations: string[] = [];
+
+ // Check for in-loop token operations
+ const loopCalls = callSites.filter((s) => s.inLoop);
+ if (loopCalls.length > 0) {
+ recommendations.push(
+ `Detected ${loopCalls.length} token call(s) executed inside loops (lines: ${loopCalls.map((s) => s.line).join(', ')}). Batch or hoist these operations to avoid multiplicative CPU and storage costs.`,
+ );
+ }
+
+ // Check for high transfer concentration
+ const transferGroup = byOperation.find((o) => o.operation === 'transfer' || o.operation === 'transfer_from');
+ if (transferGroup && transferGroup.count >= 3) {
+ recommendations.push(
+ `Multiple token transfers (${transferGroup.count} calls) account for ${transferGroup.percentageOfTotalCpu}% of token CPU budget. Consolidate transfers to the same recipient or use batched settlements.`,
+ );
+ }
+
+ // Check for repeated balance queries
+ const balanceGroup = byOperation.find((o) => o.operation === 'balance');
+ if (balanceGroup && balanceGroup.count >= 2) {
+ recommendations.push(
+ `Multiple balance queries detected (${balanceGroup.count} calls). Cache token balance in a local variable if state does not mutate between reads.`,
+ );
+ }
+
+ return {
+ callSites,
+ byOperation,
+ byAsset,
+ totalCalls: callSites.length,
+ totalEstimatedCpuInstructions,
+ totalEstimatedMemoryBytes,
+ totalEstimatedStorageReadBytes,
+ totalEstimatedStorageWriteBytes,
+ dominantOperation,
+ dominantAsset,
+ recommendations,
+ };
+}