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.
Security Finding:
sign()double-counts Soroban resource feeSubsystem: contract
Severity: Medium
Model: claude-sonnet-4.6
Impact
AssembledTransaction.sign()inflates the signed transaction's declared fee by one extraresourceFeeeach 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.transactionDataTransactionBuilder.build()then addssorobanData.resourceFee()again when constructing the envelope. UnlikeassembleTransaction(),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()orsignAndSend()signs an envelope whosefeefield 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 callsignAuthEntries()and only the final invoker callsign()once.PoC
The PoC test builds a Soroban transaction with base fee
100and simulatedresourceFee200000, confirmsassembleTransaction()produces the expected fee200100, then reproduces thesign()reclone logic. The rebuilt transaction's fee becomes400100, showing one extraresourceFeewas added. Repeating the reclone compounds the fee field further.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.