Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,6 @@ target
.soroban

config/tsconfig.tmp.json

.claude/
.copilot/
92 changes: 80 additions & 12 deletions src/contract/assembled_transaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -361,6 +361,66 @@ export class AssembledTransaction<T> {
});
}

/**
* Validate that a built transaction is a single invokeContract operation
* targeting the expected contract, and return the parsed InvokeContractArgs.
*/
private static validateInvokeContractOp(
built: Tx,
expectedContractId: string,
): xdr.InvokeContractArgs {
if (built.operations.length !== 1) {
throw new Error(
"Transaction envelope must contain exactly one operation.",
);
}

const operation = built.operations[0];

if (operation.type !== "invokeHostFunction") {
throw new Error(
"Transaction envelope does not contain an invokeHostFunction operation.",
);
}

const invokeOp = operation as Operation.InvokeHostFunction;

if (invokeOp.func.switch().name !== "hostFunctionTypeInvokeContract") {
throw new Error(
"Transaction envelope does not contain an invokeContract host function.",
);
}

const invokeContractArgs = invokeOp.func.value() as xdr.InvokeContractArgs;

let contractAddress: xdr.ScAddress;
let functionName: string;

try {
contractAddress = invokeContractArgs.contractAddress();
functionName = invokeContractArgs.functionName().toString("utf-8");
Comment thread
Shaptic marked this conversation as resolved.
} catch {
throw new Error(
"Could not extract contract address or method name from the transaction envelope.",
);
}

if (!contractAddress || !functionName) {
throw new Error(
"Could not extract contract address or method name from the transaction envelope.",
);
}

const xdrContractId = Address.fromScAddress(contractAddress).toString();
if (xdrContractId !== expectedContractId) {
throw new Error(
`Transaction envelope targets contract ${xdrContractId}, but this Client is configured for ${expectedContractId}.`,
);
}

return invokeContractArgs;
}

static fromJSON<T>(
options: Omit<AssembledTransactionOptions<T>, "args">,
{
Expand All @@ -378,6 +438,20 @@ export class AssembledTransaction<T> {
): AssembledTransaction<T> {
const txn = new AssembledTransaction(options);
txn.built = TransactionBuilder.fromXDR(tx, options.networkPassphrase) as Tx;

const invokeContractArgs = AssembledTransaction.validateInvokeContractOp(
txn.built,
options.contractId,
);

const xdrMethod = invokeContractArgs.functionName().toString("utf-8");
Comment thread
Shaptic marked this conversation as resolved.

if (xdrMethod !== options.method) {
throw new Error(
`Transaction envelope calls method '${xdrMethod}', but the provided method is '${options.method}'.`,
);
}

txn.simulationResult = {
auth: simulationResult.auth.map((a) =>
xdr.SorobanAuthorizationEntry.fromXDR(a, "base64"),
Expand Down Expand Up @@ -419,18 +493,12 @@ export class AssembledTransaction<T> {
envelope,
options.networkPassphrase,
) as Tx;
const operation = built.operations[0] as Operation.InvokeHostFunction;
if (!operation?.func?.value || typeof operation.func.value !== "function") {
throw new Error(
"Could not extract the method from the transaction envelope.",
);
}
const invokeContractArgs = operation.func.value() as xdr.InvokeContractArgs;
if (!invokeContractArgs?.functionName) {
throw new Error(
"Could not extract the method name from the transaction envelope.",
);
}

const invokeContractArgs = AssembledTransaction.validateInvokeContractOp(
built,
options.contractId,
);

const method = invokeContractArgs.functionName().toString("utf-8");
const txn = new AssembledTransaction({
...options,
Expand Down
192 changes: 190 additions & 2 deletions test/unit/server/soroban/assembled_transaction.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,18 @@ import { describe, it, beforeEach, afterEach, expect, vi } from "vitest";
import { serverUrl } from "../../../constants";
import { StellarSdk } from "../../../test-utils/stellar-sdk-import";

const { Account, Keypair, rpc, contract, SorobanDataBuilder, xdr, Address } =
StellarSdk;
const {
Account,
Keypair,
Operation,
TransactionBuilder,
TimeoutInfinite,
rpc,
contract,
SorobanDataBuilder,
xdr,
Address,
} = StellarSdk;
const { Server } = StellarSdk.rpc;

const restoreTxnData = StellarSdk.SorobanDataBuilder.fromXDR(
Expand Down Expand Up @@ -155,3 +165,181 @@ describe("AssembledTransaction", () => {
);
});
});

describe("Contract ID validation on deserialization", () => {
const networkPassphrase = "Standalone Network ; February 2017";
const keypair = Keypair.random();
const source = new Account(keypair.publicKey(), "0");

const victimContractId =
"CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM";
const attackerContractId =
"CC53XO53XO53XO53XO53XO53XO53XO53XO53XO53XO53XO53XO53WQD5";

const createSpec = (methodName: string) => {
const funcSpec = xdr.ScSpecEntry.scSpecEntryFunctionV0(
new xdr.ScSpecFunctionV0({
doc: "",
name: methodName,
inputs: [],
outputs: [xdr.ScSpecTypeDef.scSpecTypeU32()],
}),
);
return new contract.Spec([funcSpec.toXDR("base64")]);
};

function buildInvokeTx(targetContractId: string, methodName: string) {
return new TransactionBuilder(source, {
fee: "100",
networkPassphrase,
})
.setTimeout(TimeoutInfinite)
.addOperation(
Operation.invokeContractFunction({
contract: targetContractId,
function: methodName,
args: [],
}),
)
.build();
}

it("fromXDR() accepts a transaction targeting the configured contract", () => {
const tx = buildInvokeTx(victimContractId, "test");
const xdrBase64 = tx.toEnvelope().toXDR("base64");
const spec = createSpec("test");

const assembled = contract.AssembledTransaction.fromXDR(
{
contractId: victimContractId,
networkPassphrase,
rpcUrl: "https://example.com",
},
xdrBase64,
spec,
);
expect(assembled.built).toBeDefined();
});

it("fromXDR() rejects a transaction targeting a different contract", () => {
const tx = buildInvokeTx(attackerContractId, "drain");
const xdrBase64 = tx.toEnvelope().toXDR("base64");
const spec = createSpec("drain");

expect(() =>
contract.AssembledTransaction.fromXDR(
{
contractId: victimContractId,
networkPassphrase,
rpcUrl: "https://example.com",
},
xdrBase64,
spec,
),
).toThrow(
`Transaction envelope targets contract ${attackerContractId}, but this Client is configured for ${victimContractId}.`,
);
});

it("fromJSON() accepts a transaction targeting the configured contract", () => {
const tx = buildInvokeTx(victimContractId, "test");
const spec = createSpec("test");
const simulationResult = {
auth: [],
retval: xdr.ScVal.scvU32(0).toXDR("base64"),
};
const simulationTransactionData = new SorobanDataBuilder()
.build()
.toXDR("base64");

const json = JSON.stringify({
method: "test",
tx: tx.toEnvelope().toXDR("base64"),
simulationResult,
simulationTransactionData,
});

const { method, ...txData } = JSON.parse(json);
const assembled = contract.AssembledTransaction.fromJSON(
{
contractId: victimContractId,
networkPassphrase,
rpcUrl: "https://example.com",
method,
parseResultXdr: (result: any) => spec.funcResToNative(method, result),
},
txData,
);
expect(assembled.built).toBeDefined();
});

it("fromJSON() rejects a transaction targeting a different contract", () => {
const tx = buildInvokeTx(attackerContractId, "drain");
const simulationResult = {
auth: [],
retval: xdr.ScVal.scvU32(0).toXDR("base64"),
};
const simulationTransactionData = new SorobanDataBuilder()
.build()
.toXDR("base64");

const json = JSON.stringify({
method: "drain",
tx: tx.toEnvelope().toXDR("base64"),
simulationResult,
simulationTransactionData,
});

const { method, ...txData } = JSON.parse(json);

expect(() =>
contract.AssembledTransaction.fromJSON(
{
contractId: victimContractId,
networkPassphrase,
rpcUrl: "https://example.com",
method,
parseResultXdr: () => {},
},
txData,
),
).toThrow(
`Transaction envelope targets contract ${attackerContractId}, but this Client is configured for ${victimContractId}.`,
);
});

it("fromJSON() rejects a transaction with a spoofed method name", () => {
const tx = buildInvokeTx(victimContractId, "transfer");
const simulationResult = {
auth: [],
retval: xdr.ScVal.scvU32(0).toXDR("base64"),
};
const simulationTransactionData = new SorobanDataBuilder()
.build()
.toXDR("base64");

const json = JSON.stringify({
method: "safe_operation",
tx: tx.toEnvelope().toXDR("base64"),
simulationResult,
simulationTransactionData,
});

const { method, ...txData } = JSON.parse(json);

expect(() =>
contract.AssembledTransaction.fromJSON(
{
contractId: victimContractId,
networkPassphrase,
rpcUrl: "https://example.com",
method,
parseResultXdr: () => {},
},
txData,
),
).toThrow(
"Transaction envelope calls method 'transfer', but the provided method is 'safe_operation'.",
);
});
});
Loading