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
29 changes: 14 additions & 15 deletions src/contract/assembled_transaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -659,6 +659,7 @@ export class AssembledTransaction<T> {
contract.call(this.options.method, ...(this.options.args ?? [])),
)
.setTimeout(this.options.timeoutInSeconds ?? DEFAULT_TIMEOUT);
delete this.built;
await this.simulate();
return this;
}
Expand All @@ -667,7 +668,10 @@ export class AssembledTransaction<T> {
);
}

if (Api.isSimulationSuccess(this.simulation)) {
if (
Api.isSimulationSuccess(this.simulation) &&
!Api.isSimulationRestore(this.simulation)
) {
this.built = assembleTransaction(this.built, this.simulation).build();
}

Expand Down Expand Up @@ -871,20 +875,15 @@ export class AssembledTransaction<T> {
watcher?: Watcher;
} = {}): Promise<SentTransaction<T>> => {
if (!this.signed) {
// Store the original submit option
const originalSubmit = this.options.submit;

// Temporarily disable submission in signTransaction to prevent double submission
if (this.options.submit) {
this.options.submit = false;
}

try {
await this.sign({ force, signTransaction });
} finally {
// Restore the original submit option
this.options.submit = originalSubmit;
}
// Wrap signTransaction to disable submit and prevent double submission,
// without mutating the shared this.options object
const signer = signTransaction || this.options.signTransaction;
const wrappedSignTransaction: typeof signTransaction =
this.options.submit && signer
? (tx, opts) => signer(tx, { ...opts, submit: false })
: signTransaction;

await this.sign({ force, signTransaction: wrappedSignTransaction });
}
return this.send(watcher);
};
Expand Down
153 changes: 92 additions & 61 deletions src/http-client/fetch-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,69 @@ function createFetchClient(

makeRequest<T>(config: FetchClientConfig): Promise<HttpClientResponse<T>> {
return new Promise((resolve, reject) => {
// Extracted into a helper so it can be called from two sites:
// 1. After the async request-interceptor chain resolves.
// 2. Directly when there are no request interceptors (fast path).
function processRequest(
this: HttpClient,
finalConfig: HttpClientRequestConfig,
res: (value: HttpClientResponse<T>) => void,
rej: (reason?: any) => void,
) {
const adapter = finalConfig.adapter || this.defaults.adapter;
if (!adapter) {
throw new Error("No adapter available");
}
let responsePromise = adapter(finalConfig).then((axiosResponse) => {
// Transform AxiosResponse to HttpClientResponse
const httpClientResponse: HttpClientResponse<T> = {
data: axiosResponse.data,
headers: axiosResponse.headers as any, // You might want to transform headers more carefully
config: axiosResponse.config,
status: axiosResponse.status,
statusText: axiosResponse.statusText,
};
return httpClientResponse;
});

// Apply response interceptors
if (responseInterceptors.handlers.length > 0) {
const chain = responseInterceptors.handlers
.filter(
(interceptor): interceptor is NonNullable<typeof interceptor> =>
interceptor !== null,
)
.flatMap((interceptor) => [
interceptor.fulfilled,
interceptor.rejected,
]);

for (let i = 0, len = chain.length; i < len; i += 2) {
responsePromise = responsePromise
.then(
(response) => {
const fulfilledInterceptor = chain[i];
if (typeof fulfilledInterceptor === "function") {
return fulfilledInterceptor(response);
}
return response;
},
(error) => {
const rejectedInterceptor = chain[i + 1];
if (typeof rejectedInterceptor === "function") {
return rejectedInterceptor(error);
}
throw error;
},
)
.then((interceptedResponse) => interceptedResponse);
}
}

// Resolve or reject the final promise
responsePromise.then(res).catch(rej);
}

const abortController = new AbortController();
config.signal = abortController.signal;

Expand All @@ -101,7 +164,16 @@ function createFetchClient(
});
}

// Apply request interceptors
// Apply request interceptors using a promise chain so that async
// interceptors (returning Promise<HttpClientRequestConfig>) are
// properly awaited before the config is passed downstream. A previous
// implementation used a synchronous try/catch loop which silently
// passed unresolved promise objects as the config when an interceptor
// was async.
//
// The chain is built as [fulfilled, rejected, fulfilled, rejected, ...]
// and wired via .then(onFulfilled, onRejected) so each pair can
// recover from an upstream error (matching Axios interceptor semantics).
let modifiedConfig = config;
if (requestInterceptors.handlers.length > 0) {
const chain = requestInterceptors.handlers
Expand All @@ -113,71 +185,30 @@ function createFetchClient(
interceptor.fulfilled,
interceptor.rejected,
]);
let configPromise = Promise.resolve(modifiedConfig);
for (let i = 0, len = chain.length; i < len; i += 2) {
const onFulfilled = chain[i];
const onRejected = chain[i + 1];
try {
if (onFulfilled) modifiedConfig = onFulfilled(modifiedConfig);
} catch (error) {
if (onRejected) (onRejected as InterceptorRejected)?.(error);
reject(error);
return;
}
configPromise = configPromise.then(
chain[i] as
| ((
val: HttpClientRequestConfig,
) =>
| HttpClientRequestConfig
| Promise<HttpClientRequestConfig>)
| undefined,
chain[i + 1] as InterceptorRejected | undefined,
);
}
}

const adapter = modifiedConfig.adapter || this.defaults.adapter;
if (!adapter) {
throw new Error("No adapter available");
}
let responsePromise = adapter(modifiedConfig).then((axiosResponse) => {
// Transform AxiosResponse to HttpClientResponse
const httpClientResponse: HttpClientResponse<T> = {
data: axiosResponse.data,
headers: axiosResponse.headers as any, // You might want to transform headers more carefully
config: axiosResponse.config,
status: axiosResponse.status,
statusText: axiosResponse.statusText,
};
return httpClientResponse;
});

// Apply response interceptors
if (responseInterceptors.handlers.length > 0) {
const chain = responseInterceptors.handlers
.filter(
(interceptor): interceptor is NonNullable<typeof interceptor> =>
interceptor !== null,
)
.flatMap((interceptor) => [
interceptor.fulfilled,
interceptor.rejected,
]);

for (let i = 0, len = chain.length; i < len; i += 2) {
responsePromise = responsePromise
.then(
(response) => {
const fulfilledInterceptor = chain[i];
if (typeof fulfilledInterceptor === "function") {
return fulfilledInterceptor(response);
}
return response;
},
(error) => {
const rejectedInterceptor = chain[i + 1];
if (typeof rejectedInterceptor === "function") {
return rejectedInterceptor(error);
}
throw error;
},
)
.then((interceptedResponse) => interceptedResponse);
}
configPromise
.then((resolvedConfig) => {
processRequest.call(this, resolvedConfig, resolve, reject);
})
.catch(reject);
return;
}

// Resolve or reject the final promise
responsePromise.then(resolve).catch(reject);
// No request interceptors — skip the chain and process immediately.
processRequest.call(this, modifiedConfig, resolve, reject);
});
},

Expand Down
2 changes: 1 addition & 1 deletion src/rpc/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -571,7 +571,7 @@ export namespace Api {
amount: string;
authorized: boolean;
clawback: boolean;
revocable?: boolean; // only present for trustlines
authorizedToMaintainLiabilities?: boolean; // only present for trustlines
Copy link

Copilot AI Apr 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Renaming balanceEntry.revocable to authorizedToMaintainLiabilities is a breaking public API change (and revocable is referenced in the existing changelog entry for rpc.Api.BalanceResponse). If the intent is to correct semantics rather than introduce a new field, consider keeping revocable as a deprecated alias (or updating all public docs/changelog in the same PR) to avoid silently breaking downstream TypeScript consumers.

Suggested change
authorizedToMaintainLiabilities?: boolean; // only present for trustlines
authorizedToMaintainLiabilities?: boolean; // only present for trustlines
/** @deprecated Use authorizedToMaintainLiabilities instead. */
revocable?: boolean; // legacy alias for trustlines

Copilot uses AI. Check for mistakes.

lastModifiedLedgerSeq?: number;
liveUntilLedgerSeq?: number;
Expand Down
9 changes: 3 additions & 6 deletions src/rpc/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,6 @@ import {
Account,
Address,
Asset,
AuthClawbackEnabledFlag,
AuthRequiredFlag,
AuthRevocableFlag,
Contract,
FeeBumpTransaction,
Keypair,
Expand Down Expand Up @@ -421,9 +418,9 @@ export class RpcServer {
balanceEntry: {
amount: tl.balance().toString(),
// Extract actual flags from the coalesced value.
authorized: Boolean(tl.flags() & AuthRequiredFlag),
clawback: Boolean(tl.flags() & AuthClawbackEnabledFlag),
revocable: Boolean(tl.flags() & AuthRevocableFlag),
authorized: Boolean(tl.flags() & 0x1), // AUTHORIZED_FLAG
clawback: Boolean(tl.flags() & 0x4), // TRUSTLINE_CLAWBACK_ENABLED_FLAG
authorizedToMaintainLiabilities: Boolean(tl.flags() & 0x2), // AUTHORIZED_TO_MAINTAIN_LIABILITIES_FLAG
},
};
} else if (StrKey.isValidContract(addr)) {
Expand Down
6 changes: 6 additions & 0 deletions src/webauth/challenge_transaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -540,6 +540,12 @@ export function verifyChallengeTxSigners(
signersFound.splice(signersFound.indexOf(clientSigningKey), 1);
}

if (signersFound.length === 0) {
throw new InvalidChallengeError(
"None of the given signers match the transaction signatures",
);
}

return signersFound;
}

Expand Down
35 changes: 33 additions & 2 deletions test/unit/server/soroban/get_classic_entries.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -178,8 +178,8 @@ describe("Server#getTrustline", () => {
limit: xdr.Int64.fromString("1000"),
flags:
Number(tl.balanceEntry!.authorized) |
Number(tl.balanceEntry!.clawback) |
Number(tl.balanceEntry!.revocable!),
(Number(tl.balanceEntry!.authorizedToMaintainLiabilities) << 1) |
(Number(tl.balanceEntry!.clawback) << 2),
ext: new (xdr.TrustLineEntryExt as any)(0),
});
},
Expand All @@ -188,6 +188,37 @@ describe("Server#getTrustline", () => {
2, // extra for getLatestLedger call
));

it("correctly decodes trustline flags including clawback", () => {
const clawbackEntry = new xdr.TrustLineEntry({
accountId,
asset: asset.toTrustLineXDRObject(),
balance: xdr.Int64.fromString("500"),
limit: xdr.Int64.fromString("1000"),
flags: 5, // authorized (0x1) + clawback (0x4)
ext: new (xdr.TrustLineEntryExt as any)(0),
});
const clawbackEntryXDR = xdr.LedgerEntryData.trustline(clawbackEntry).toXDR("base64");

return expectLedgerEntryFound(
mockPost,
trustlineKeyXDR,
clawbackEntryXDR,
async () => {
const tl: Api.BalanceResponse = await server.getAssetBalance(
account,
asset,
);
expect(tl.balanceEntry!.authorized).toBe(true);
expect(tl.balanceEntry!.clawback).toBe(true);
expect(tl.balanceEntry!.authorizedToMaintainLiabilities).toBe(false);
return clawbackEntry;
},
xdr.TrustLineEntry,
clawbackEntry.toXDR("base64"),
2,
);
});

it("throws an error when the trustline is missing", () =>
expectLedgerEntryNotFound(
mockPost,
Expand Down
47 changes: 47 additions & 0 deletions test/unit/utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2356,6 +2356,53 @@ describe("Utils", () => {
expect(signersFound.indexOf(clientSigningKey.publicKey())).toEqual(-1);
});

it("throws an error if challenge is signed only by server and client_domain key but no client signer", () => {
serverKP = StellarSdk.Keypair.random();
const clientKP = StellarSdk.Keypair.random();
const clientSigningKey = StellarSdk.Keypair.random();
const challenge = WebAuth.buildChallengeTx(
serverKP,
clientKP.publicKey(),
"SDF",
300,
StellarSdk.Networks.TESTNET,
"testanchor.stellar.org",
null,
"testdomain",
clientSigningKey.publicKey(),
);

vi.advanceTimersByTime(200);

const transaction = new StellarSdk.Transaction(
challenge,
StellarSdk.Networks.TESTNET,
);

// Only sign with clientSigningKey, NOT clientKP (no actual client signer)
transaction.sign(clientSigningKey);

const signedChallenge = transaction
.toEnvelope()
.toXDR("base64")
.toString();

expect(() =>
WebAuth.verifyChallengeTxSigners(
signedChallenge,
serverKP.publicKey(),
StellarSdk.Networks.TESTNET,
[clientKP.publicKey()],
"SDF",
"testanchor.stellar.org",
),
).toThrow(
new StellarSdk.WebAuth.InvalidChallengeError(
"None of the given signers match the transaction signatures",
),
);
});

it("throws an error if a challenge with a client_domain operation doesn't have a matching signature", () => {
serverKP = StellarSdk.Keypair.random();
const clientKP = StellarSdk.Keypair.random();
Expand Down
Loading