Skip to content

[AI Security] Medium: sign() double-counts Soroban resource fee, inflating transaction fee bid #1357

@sagpatil

Description

@sagpatil

Security Finding: sign() double-counts Soroban resource fee

Subsystem: contract
Severity: Medium
Model: claude-sonnet-4.6

Impact

AssembledTransaction.sign() inflates the signed transaction's declared fee by one extra resourceFee each time it reclones the transaction. This raises the user's maximum inclusion-fee bid and creates real overpayment risk under fee pressure.

Root Cause

AssembledTransaction.sign() rebuilds an already-assembled Soroban transaction with:

  • sorobanData: this.simulationData.transactionData

TransactionBuilder.build() then adds sorobanData.resourceFee() again when constructing the envelope. Unlike assembleTransaction(), sign() does not first subtract any pre-existing resource fee from the incoming fee, so the same resource fee is counted twice.

Attack Vector

No malicious RPC is required. A normal contract flow that simulates a Soroban transaction and then calls sign() or signAndSend() signs an envelope whose fee field is higher than the fee produced by simulation. This increases the transaction's maximum inclusion-fee bid and can cause users to pay more than intended when fees rise.

Repeated manual sign() calls can compound the inflation, although the SDK's documented multi-party flow usually has non-invokers call signAuthEntries() and only the final invoker call sign() once.

PoC

The PoC test builds a Soroban transaction with base fee 100 and simulated resourceFee 200000, confirms assembleTransaction() produces the expected fee 200100, then reproduces the sign() reclone logic. The rebuilt transaction's fee becomes 400100, showing one extra resourceFee was added. Repeating the reclone compounds the fee field further.

// PoC: sign() double-counts resource fee when re-cloning transaction
// Hypothesis: H012
// Severity: Medium
import { describe, it, expect } from "vitest";
import {
  xdr,
  SorobanDataBuilder,
  Account,
  Keypair,
  Networks,
  Contract,
  TransactionBuilder,
} from "@stellar/stellar-base";
import { assembleTransaction } from "../../src/rpc/transaction";

describe("Security PoC - sign() double-counts resource fee", () => {
  const RESOURCE_FEE = 200000;
  const BASE_FEE = "100";
  const keypair = Keypair.random();
  const account = new Account(keypair.publicKey(), "100");
  const contract = new Contract(
    "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM",
  );

  it("should demonstrate fee double-counting on re-clone", () => {
    const raw = new TransactionBuilder(account, {
      fee: BASE_FEE,
      networkPassphrase: Networks.TESTNET,
    })
      .addOperation(contract.call("transfer"))
      .setTimeout(300)
      .build();

    const sorobanData = new SorobanDataBuilder()
      .setResourceFee(RESOURCE_FEE.toString())
      .build();

    const mockSimulation = {
      id: "mock-sim",
      _parsed: true,
      events: [],
      transactionData: new SorobanDataBuilder().setResourceFee(
        RESOURCE_FEE.toString(),
      ),
      result: {
        auth: [],
        retval: xdr.ScVal.scvVoid(),
      },
      cost: { cpuInsns: "0", memBytes: "0" },
      latestLedger: 100,
      minResourceFee: RESOURCE_FEE.toString(),
    };

    const assembledBuilder = assembleTransaction(raw, mockSimulation as any);
    const firstBuilt = assembledBuilder.build();
    const feeAfterAssemble = parseInt(firstBuilt.fee, 10);

    // Verify correct assembly: baseFee(100) + resourceFee(200000) = 200100
    expect(feeAfterAssemble).toBe(parseInt(BASE_FEE, 10) + RESOURCE_FEE);

    // Replicate what sign() does at assembled_transaction.ts
    const recloned = TransactionBuilder.cloneFrom(firstBuilt, {
      fee: firstBuilt.fee, // "200100" — already includes resource fee!
      timebounds: undefined,
      sorobanData: sorobanData,
    })
      .setTimeout(300)
      .build();

    const feeAfterReclone = parseInt(recloned.fee, 10);

    expect(feeAfterReclone).toBe(feeAfterAssemble + RESOURCE_FEE);

    console.log(`Fee after assemble: ${feeAfterAssemble} (correct)`);
    console.log(`Overpayment: ${feeAfterReclone - feeAfterAssemble} stroops (= resource fee)`);
  });

  it("should demonstrate fee compounds with multiple sign() calls", () => {
    const raw = new TransactionBuilder(account, {
      fee: BASE_FEE,
      networkPassphrase: Networks.TESTNET,
    })
      .addOperation(contract.call("transfer"))
      .setTimeout(300)
      .build();

    const sorobanData = new SorobanDataBuilder()
      .setResourceFee(RESOURCE_FEE.toString())
      .build();

    const mockSimulation = {
      id: "mock-sim",
      _parsed: true,
      events: [],
      transactionData: new SorobanDataBuilder().setResourceFee(
        RESOURCE_FEE.toString(),
      ),
      result: { auth: [], retval: xdr.ScVal.scvVoid() },
      cost: { cpuInsns: "0", memBytes: "0" },
      latestLedger: 100,
      minResourceFee: RESOURCE_FEE.toString(),
    };

    const firstBuilt = assembleTransaction(raw, mockSimulation as any).build();
    const feeAfterAssemble = parseInt(firstBuilt.fee, 10);

    // First sign() re-clone
    const afterSign1 = TransactionBuilder.cloneFrom(firstBuilt, {
      fee: firstBuilt.fee,
      timebounds: undefined,
      sorobanData,
    }).setTimeout(300).build();

    // Second sign() re-clone
    const afterSign2 = TransactionBuilder.cloneFrom(afterSign1, {
      fee: afterSign1.fee,
      timebounds: undefined,
      sorobanData,
    }).setTimeout(300).build();

    const fee1 = parseInt(afterSign1.fee, 10);
    const fee2 = parseInt(afterSign2.fee, 10);

    expect(fee1).toBe(feeAfterAssemble + RESOURCE_FEE);       // 400100
    expect(fee2).toBe(feeAfterAssemble + 2 * RESOURCE_FEE);   // 600100

    console.log(`Fee after assemble: ${feeAfterAssemble}`);
    console.log(`Fee after 1st sign(): ${fee1} (+${RESOURCE_FEE})`);
    console.log(`Fee after 2nd sign(): ${fee2} (+${RESOURCE_FEE})`);
  });
});

Recommendation

Add a regression test covering the real sign()/signAndSend() path so the signed envelope fee matches the simulated envelope fee after a single sign call.

Metadata

Metadata

Assignees

No one assigned

    Labels

    ai-generatedGenerated by AI security analysis pipelinesecuritySecurity vulnerability or concern

    Type

    No type

    Projects

    Status

    Backlog (Not Ready)

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions