Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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/
51 changes: 51 additions & 0 deletions src/contract/assembled_transaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -378,6 +378,39 @@ export class AssembledTransaction<T> {
): AssembledTransaction<T> {
const txn = new AssembledTransaction(options);
txn.built = TransactionBuilder.fromXDR(tx, options.networkPassphrase) as Tx;

if (txn.built.operations.length !== 1) {
throw new Error(
"Transaction envelope must contain exactly one operation.",
);
}

const operation = txn.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.",
);
}

Comment thread
quietbits marked this conversation as resolved.
Outdated
Comment thread
quietbits marked this conversation as resolved.
Outdated
const xdrContractId = Address.fromScAddress(
invokeContractArgs.contractAddress(),
).toString();

if (xdrContractId !== options.contractId) {
throw new Error(
`Transaction envelope targets contract ${xdrContractId}, but this Client is configured for ${options.contractId}.`,
);
}

txn.simulationResult = {
auth: simulationResult.auth.map((a) =>
xdr.SorobanAuthorizationEntry.fromXDR(a, "base64"),
Expand Down Expand Up @@ -419,6 +452,13 @@ export class AssembledTransaction<T> {
envelope,
options.networkPassphrase,
) as Tx;

if (built.operations.length !== 1) {
throw new Error(
"Transaction envelope must contain exactly one operation.",
);
}

const operation = built.operations[0] as Operation.InvokeHostFunction;
if (!operation?.func?.value || typeof operation.func.value !== "function") {
throw new Error(
Expand All @@ -431,6 +471,17 @@ export class AssembledTransaction<T> {
"Could not extract the method name from the transaction envelope.",
);
}

const xdrContractId = Address.fromScAddress(
invokeContractArgs.contractAddress(),
).toString();

if (xdrContractId !== options.contractId) {
throw new Error(
`Transaction envelope targets contract ${xdrContractId}, but this Client is configured for ${options.contractId}.`,
);
}
Comment thread
quietbits marked this conversation as resolved.
Outdated

const method = invokeContractArgs.functionName().toString("utf-8");
const txn = new AssembledTransaction({
...options,
Expand Down
157 changes: 155 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,146 @@ 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}.`,
);
});
});
Loading