Skip to content

Commit 9ec7ffd

Browse files
Ryang-21claude
andauthored
Internal agent bug fixes 2 (#1373)
* fix: correct pollTransaction off-by-one and requestAirdrop error path * fix: add parentheses to fix operator precedence in parseSuccessful * fix: multiple spec.ts correctness issues * fix: advance WASM parser offset on skipped custom sections * fix: federation server domain validation and URL mutation * fix: clone URL in stream() to prevent mutation of shared builder state * fix: store original operation for restore path in buildWithOp * fix: include port in SERVER_TIME_MAP key to prevent cross-port collision * fix: handle Err variant in funcResToNative for Result types * fix: refactor AccountResponse constructor for type-safe property assignment * add transactions field to Api.AccountRecord and AccountResponse * add range checks for U32 and I32 values in Spec class --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent cbb53ed commit 9ec7ffd

13 files changed

Lines changed: 149 additions & 46 deletions

File tree

CHANGELOG.md

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,35 @@ A breaking change will get clearly marked in this log.
66

77
## Unreleased
88

9+
### Fixed
10+
* `RpcServer.pollTransaction` off-by-one: the polling loop used `<` instead of `<=`, causing one fewer attempt than configured([#1373](https://github.com/stellar/js-stellar-sdk/pull/1373)).
11+
* `requestAirdrop` error path: fixed incorrect property access (`error.response.detail` instead of `error.response.data.detail`) when checking for `createAccountAlreadyExist` ([#1373](https://github.com/stellar/js-stellar-sdk/pull/1373)).
12+
* Operator precedence bug in `parseSuccessful`: `sim.results?.length ?? 0 > 0` was parsed as `?? (0 > 0)`, causing simulation results and state changes to never be included in the parsed response ([#1373](https://github.com/stellar/js-stellar-sdk/pull/1373)).
13+
* `Spec.typeRef` now properly handles `scSpecTypeResult` by returning the JSON schema for the `okType`, instead of silently breaking out of the switch ([#1373](https://github.com/stellar/js-stellar-sdk/pull/1373)).
14+
* `structToJsonSchema` now places `additionalProperties: false` on the schema object itself rather than incorrectly nesting it inside `properties` ([#1373](https://github.com/stellar/js-stellar-sdk/pull/1373)).
15+
* Fixed bigint-to-U32/I32 conversion in `Spec` using `Number(val)` instead of `val as number` (a no-op for bigints) ([#1373](https://github.com/stellar/js-stellar-sdk/pull/1373)).
16+
* Fixed missing template literal `$` in two `Spec` error messages that were not interpolated ([#1373](https://github.com/stellar/js-stellar-sdk/pull/1373)).
17+
* WASM custom section parser: when a section was skipped (invalid name length), the offset was not advanced, causing an infinite loop or incorrect parsing of subsequent sections ([#1373](https://github.com/stellar/js-stellar-sdk/pull/1373)).
18+
* `FederationServer.resolve` now validates domains per RFC 1035, rejecting malformed domains. Port numbers are also accepted in the domain ([#1373](https://github.com/stellar/js-stellar-sdk/pull/1373)).
19+
* `FederationServer` URL mutation: `resolveAddress`, `resolveAccountId`, and `resolveTransactionId` mutated the shared `serverURL` by appending query params on each call. Fixed by cloning the URL before modifying ([#1373](https://github.com/stellar/js-stellar-sdk/pull/1373)).
20+
* `CallBuilder.stream()` URL mutation: `stream()` mutated the shared `this.url` by adding query params, corrupting the builder for subsequent calls. Fixed by cloning the URL ([#1373](https://github.com/stellar/js-stellar-sdk/pull/1373)).
21+
* `AssembledTransaction` restore path: when `buildWithOp` was used and automatic state restoration was needed, the rebuild incorrectly reconstructed the operation via `contract.call()` instead of reusing the original operation ([#1373](https://github.com/stellar/js-stellar-sdk/pull/1373)).
22+
* `SERVER_TIME_MAP` port collision: the Horizon time-sync cache keyed entries by hostname only, so two servers on different ports of the same host shared a cache entry. Fixed by including the port in the key ([#1373](https://github.com/stellar/js-stellar-sdk/pull/1373)).
23+
* `Spec.funcResToNative` now correctly returns an `Err` instance when a contract function with a `Result` return type returns an error, instead of throwing while decoding it as the `Ok` type ([#1373](https://github.com/stellar/js-stellar-sdk/pull/1373)).
24+
* SEP-10: `verifyChallengeTxSigners` now rejects challenges signed only by the server and `client_domain` key with no actual client signer, instead of returning an empty signers list ([#1372](https://github.com/stellar/js-stellar-sdk/pull/1372)).
25+
* `getAssetBalance` used incorrect flag bitmask constants (`AuthRequiredFlag`, `AuthRevocableFlag`, `AuthClawbackEnabledFlag`) which are account-level flags, not trustline-level flags. Replaced with the correct trustline flag bitmasks (`0x1`, `0x2`, `0x4`) ([#1372](https://github.com/stellar/js-stellar-sdk/pull/1372)).
26+
* `AssembledTransaction.simulate` did not clear `this.built` before re-simulating after a state restoration rebuild, causing it to assemble stale transaction data ([#1372](https://github.com/stellar/js-stellar-sdk/pull/1372)).
27+
* `AssembledTransaction.signAndSend` mutated the shared `this.options.submit` flag to prevent double submission. Replaced with a wrapper around `signTransaction` that injects `submit: false` without mutating shared state ([#1372](https://github.com/stellar/js-stellar-sdk/pull/1372)).
28+
* Fetch HTTP client: async request interceptors were not awaited — the synchronous `try/catch` loop passed unresolved promise objects as the config. Replaced with a proper `.then()` chain matching Axios interceptor semantics ([#1372](https://github.com/stellar/js-stellar-sdk/pull/1372)).
29+
30+
### Added
31+
* `AccountResponse` constructor now uses explicit field-by-field assignment instead of `Object.entries` dynamic assignment for type safety ([#1373](https://github.com/stellar/js-stellar-sdk/pull/1373)).
32+
* Added `transactions` collection to `Api.AccountRecord` and `AccountResponse` ([#1373](https://github.com/stellar/js-stellar-sdk/pull/1373)).
33+
* Added range checks for U32/I32 values in `Spec`: bigint values are now validated against min/max bounds before conversion, throwing a `RangeError` instead of silently truncating ([#1373](https://github.com/stellar/js-stellar-sdk/pull/1373)).
34+
35+
### Deprecated
36+
* `BalanceResponse.revocable` is deprecated in favor of `authorizedToMaintainLiabilities`, which correctly reflects the trustline flag semantics ([#1372](https://github.com/stellar/js-stellar-sdk/pull/1372)).
37+
938

1039
## [v15.0.1](https://github.com/stellar/js-stellar-sdk/compare/v15.0.0...v15.0.1)
1140

src/contract/assembled_transaction.ts

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -268,6 +268,12 @@ export class AssembledTransaction<T> {
268268
*/
269269
public raw?: TransactionBuilder;
270270

271+
/**
272+
* Stores the original operation from `buildWithOp` for reuse during
273+
* automatic state restoration rebuilds.
274+
*/
275+
private originalOp?: xdr.Operation;
276+
271277
/**
272278
* The Transaction as it was built with `raw.build()` right before
273279
* simulation. Once this is set, modifying `raw` will have no effect unless
@@ -590,6 +596,7 @@ export class AssembledTransaction<T> {
590596
options: AssembledTransactionOptions<T>,
591597
): Promise<AssembledTransaction<T>> {
592598
const tx = new AssembledTransaction(options);
599+
tx.originalOp = operation;
593600
const account = await getAccount(options, tx.server);
594601
tx.raw = new TransactionBuilder(account, {
595602
fee: options.fee ?? BASE_FEE,
@@ -650,14 +657,17 @@ export class AssembledTransaction<T> {
650657
);
651658
if (result.status === Api.GetTransactionStatus.SUCCESS) {
652659
// need to rebuild the transaction with bumped account sequence number
653-
const contract = new Contract(this.options.contractId);
660+
const op = this.originalOp
661+
? this.originalOp
662+
: new Contract(this.options.contractId).call(
663+
this.options.method,
664+
...(this.options.args ?? []),
665+
);
654666
this.raw = new TransactionBuilder(account, {
655667
fee: this.options.fee ?? BASE_FEE,
656668
networkPassphrase: this.options.networkPassphrase,
657669
})
658-
.addOperation(
659-
contract.call(this.options.method, ...(this.options.args ?? [])),
660-
)
670+
.addOperation(op)
661671
.setTimeout(this.options.timeoutInSeconds ?? DEFAULT_TIMEOUT);
662672
delete this.built;
663673
await this.simulate();

src/contract/spec.ts

Lines changed: 24 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import {
77
Contract,
88
scValToBigInt,
99
} from "@stellar/stellar-base";
10-
import { Ok } from "./rust_result";
10+
import { Ok, Err } from "./rust_result";
1111
import { processSpecEntryStream } from "./utils";
1212
import { specFromWasm } from "./wasm_spec_parser";
1313

@@ -277,8 +277,8 @@ function typeRef(typeDef: xdr.ScSpecTypeDef): JSONSchema7 {
277277
return typeRef(opt.valueType());
278278
}
279279
case xdr.ScSpecType.scSpecTypeResult().value: {
280-
// throw new Error('Result type not supported');
281-
break;
280+
const result = typeDef.result();
281+
return typeRef(result.okType());
282282
}
283283
case xdr.ScSpecType.scSpecTypeVec().value: {
284284
const arr = typeDef.vec();
@@ -368,11 +368,11 @@ function structToJsonSchema(udt: xdr.ScSpecUdtStructV0): object {
368368
}
369369
const description = udt.doc().toString();
370370
const { properties, required }: any = argsAndRequired(fields);
371-
properties.additionalProperties = false;
372371
return {
373372
description,
374373
properties,
375374
required,
375+
additionalProperties: false,
376376
type: "object",
377377
};
378378
}
@@ -623,6 +623,9 @@ export class Spec {
623623
}
624624
const output = outputs[0];
625625
if (output.switch().value === xdr.ScSpecType.scSpecTypeResult().value) {
626+
if (val.switch().value === xdr.ScValType.scvError().value) {
627+
return new Err({ message: val.error().toXDR("base64") });
628+
}
626629
return new Ok(this.scValToNative(val, output.result().okType()));
627630
}
628631
return this.scValToNative(val, output);
@@ -802,9 +805,22 @@ export class Spec {
802805
case "bigint": {
803806
switch (value) {
804807
case xdr.ScSpecType.scSpecTypeU32().value:
805-
return xdr.ScVal.scvU32(val as number);
808+
if (
809+
BigInt(val) < BigInt(xdr.Uint32.MIN_VALUE) ||
810+
BigInt(val) > BigInt(xdr.Uint32.MAX_VALUE)
811+
) {
812+
throw new RangeError(`Value ${val} is out of range for U32`);
813+
}
814+
return xdr.ScVal.scvU32(Number(val));
806815
case xdr.ScSpecType.scSpecTypeI32().value:
807-
return xdr.ScVal.scvI32(val as number);
816+
if (
817+
// TODO: remove the `-` cast on the min value once js-xdr fixes the issue where it treats the min value as unsigned
818+
BigInt(val) < -BigInt(xdr.Int32.MIN_VALUE) ||
819+
BigInt(val) > BigInt(xdr.Int32.MAX_VALUE)
820+
) {
821+
throw new RangeError(`Value ${val} is out of range for I32`);
822+
}
823+
return xdr.ScVal.scvI32(Number(val));
808824
case xdr.ScSpecType.scSpecTypeU64().value:
809825
case xdr.ScSpecType.scSpecTypeI64().value:
810826
case xdr.ScSpecType.scSpecTypeU128().value:
@@ -1102,12 +1118,12 @@ export class Spec {
11021118
}
11031119
const name = vec[0].sym().toString();
11041120
if (vec[0].switch().value !== xdr.ScValType.scvSymbol().value) {
1105-
throw new Error(`{vec[0]} is not a symbol`);
1121+
throw new Error(`${vec[0]} is not a symbol`);
11061122
}
11071123
const entry = udt.cases().find(findCase(name));
11081124
if (!entry) {
11091125
throw new Error(
1110-
`failed to find entry ${name} in union {udt.name().toString()}`,
1126+
`failed to find entry ${name} in union ${udt.name().toString()}`,
11111127
);
11121128
}
11131129
const res: Union<any> = { tag: name };

src/contract/utils.ts

Lines changed: 15 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -153,24 +153,24 @@ export function parseWasmCustomSections(
153153
// Custom section
154154
const nameLen = readVarUint32();
155155

156-
if (nameLen === 0 || offset + nameLen > start + sectionLength) continue;
157-
158-
const nameBytes = read(nameLen);
159-
const payload = read(sectionLength - (offset - start));
160-
161-
try {
162-
const name = new TextDecoder("utf-8", { fatal: true }).decode(
163-
nameBytes,
164-
);
165-
if (payload.length > 0) {
166-
sections.set(name, (sections.get(name) || []).concat(payload));
156+
if (nameLen > 0 && offset + nameLen <= start + sectionLength) {
157+
const nameBytes = read(nameLen);
158+
const payload = read(sectionLength - (offset - start));
159+
160+
try {
161+
const name = new TextDecoder("utf-8", { fatal: true }).decode(
162+
nameBytes,
163+
);
164+
if (payload.length > 0) {
165+
sections.set(name, (sections.get(name) || []).concat(payload));
166+
}
167+
} catch {
168+
/* Invalid UTF-8 */
167169
}
168-
} catch {
169-
/* Invalid UTF-8 */
170170
}
171-
} else {
172-
offset += sectionLength; // Skip other sections
173171
}
172+
// Always advance to end of section
173+
offset = start + sectionLength;
174174
}
175175

176176
return sections;

src/federation/server.ts

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,17 @@ export class FederationServer {
9090
if (addressParts.length !== 2 || !domain) {
9191
return Promise.reject(new Error("Invalid Stellar address"));
9292
}
93+
94+
// Validate domain per RFC 1035 (as required by SEP-0002): each dot-separated
95+
// label must start with a letter, end with a letter or digit, and contain only
96+
// letters, digits, or hyphens.
97+
if (
98+
!/^(?:[A-Za-z](?:[A-Za-z0-9-]*[A-Za-z0-9])?\.)*[A-Za-z](?:[A-Za-z0-9-]*[A-Za-z0-9])?(?::\d+)?$/.test(
99+
domain,
100+
)
101+
) {
102+
return Promise.reject(new Error("Invalid domain in Stellar address"));
103+
}
93104
const federationServer = await FederationServer.createForDomain(
94105
domain,
95106
opts,
@@ -174,7 +185,9 @@ export class FederationServer {
174185
}
175186
stellarAddress = `${address}*${this.domain}`;
176187
}
177-
const url = this.serverURL.query({ type: "name", q: stellarAddress });
188+
const url = this.serverURL
189+
.clone()
190+
.query({ type: "name", q: stellarAddress });
178191
return this._sendRequest(url);
179192
}
180193

@@ -188,7 +201,7 @@ export class FederationServer {
188201
* @throws {BadResponseError} Will throw an error if the server query fails with an improper response.
189202
*/
190203
public async resolveAccountId(accountId: string): Promise<Api.Record> {
191-
const url = this.serverURL.query({ type: "id", q: accountId });
204+
const url = this.serverURL.clone().query({ type: "id", q: accountId });
192205
return this._sendRequest(url);
193206
}
194207

@@ -204,7 +217,9 @@ export class FederationServer {
204217
public async resolveTransactionId(
205218
transactionId: string,
206219
): Promise<Api.Record> {
207-
const url = this.serverURL.query({ type: "txid", q: transactionId });
220+
const url = this.serverURL
221+
.clone()
222+
.query({ type: "txid", q: transactionId });
208223
return this._sendRequest(url);
209224
}
210225

src/horizon/account_response.ts

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -47,15 +47,38 @@ export class AccountResponse {
4747
public readonly operations!: ServerApi.CallCollectionFunction<ServerApi.OperationRecord>;
4848
public readonly payments!: ServerApi.CallCollectionFunction<ServerApi.PaymentOperationRecord>;
4949
public readonly trades!: ServerApi.CallCollectionFunction<ServerApi.TradeRecord>;
50+
public readonly transactions!: ServerApi.CallCollectionFunction<ServerApi.TransactionRecord>;
5051
private readonly _baseAccount: BaseAccount;
5152

5253
constructor(response: ServerApi.AccountRecord) {
5354
this._baseAccount = new BaseAccount(response.account_id, response.sequence);
5455
// Extract response fields
55-
// TODO: do it in type-safe manner.
56-
Object.entries(response).forEach(([key, value]) => {
57-
(this as any)[key] = value;
58-
});
56+
this.effects = response.effects;
57+
this.offers = response.offers;
58+
this.operations = response.operations;
59+
this.payments = response.payments;
60+
this.trades = response.trades;
61+
this.data = response.data;
62+
this.transactions = response.transactions;
63+
this.id = response.id;
64+
this.paging_token = response.paging_token;
65+
this.account_id = response.account_id;
66+
this.sequence = response.sequence;
67+
this.sequence_ledger = response.sequence_ledger;
68+
this.sequence_time = response.sequence_time;
69+
this.subentry_count = response.subentry_count;
70+
this.home_domain = response.home_domain;
71+
this.inflation_destination = response.inflation_destination;
72+
this.last_modified_ledger = response.last_modified_ledger;
73+
this.last_modified_time = response.last_modified_time;
74+
this.thresholds = response.thresholds;
75+
this.flags = response.flags;
76+
this.balances = response.balances;
77+
this.signers = response.signers;
78+
this.data_attr = response.data_attr;
79+
this.sponsor = response.sponsor;
80+
this.num_sponsoring = response.num_sponsoring;
81+
this.num_sponsored = response.num_sponsored;
5982
}
6083

6184
/**

src/horizon/call_builder.ts

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -126,8 +126,9 @@ export class CallBuilder<
126126

127127
this.checkFilter();
128128

129-
this.url.setQuery("X-Client-Name", "js-stellar-sdk");
130-
this.url.setQuery("X-Client-Version", version);
129+
const streamUrl = this.url.clone();
130+
streamUrl.setQuery("X-Client-Name", "js-stellar-sdk");
131+
streamUrl.setQuery("X-Client-Version", version);
131132

132133
// Extract custom app headers from httpClient defaults and add as query params
133134
// (EventSource doesn't support custom headers, so we use query params)
@@ -145,7 +146,7 @@ export class CallBuilder<
145146
value = headers[name];
146147
}
147148
if (value) {
148-
this.url.setQuery(name, value);
149+
streamUrl.setQuery(name, value);
149150
}
150151
});
151152
}
@@ -171,7 +172,7 @@ export class CallBuilder<
171172

172173
const createEventSource = (): EventSource => {
173174
try {
174-
es = new EventSource(this.url.toString());
175+
es = new EventSource(streamUrl.toString());
175176
} catch (err) {
176177
if (options.onerror) {
177178
options.onerror(err as MessageEvent);
@@ -208,7 +209,7 @@ export class CallBuilder<
208209
? this._parseRecord(JSON.parse(message.data))
209210
: message;
210211
if (result.paging_token) {
211-
this.url.setQuery("cursor", result.paging_token);
212+
streamUrl.setQuery("cursor", result.paging_token);
212213
}
213214
clearTimeout(timeout);
214215
createTimeout();

src/horizon/horizon_axios_client.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,10 @@ export function createHttpClient(headers?: Record<string, string>): HttpClient {
4343
});
4444

4545
httpClient.interceptors.response.use((response) => {
46-
const hostname = URI(response.config.url!).hostname();
46+
const uri = URI(response.config.url!);
47+
const hostname = uri.port()
48+
? `${uri.hostname()}:${uri.port()}`
49+
: uri.hostname();
4750
let serverTime = 0;
4851
if (response.headers instanceof Headers) {
4952
const dateHeader = response.headers.get("date");

src/horizon/server.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -155,7 +155,10 @@ export class HorizonServer {
155155
_isRetry: boolean = false,
156156
): Promise<HorizonServer.Timebounds> {
157157
// httpClient instead of this.ledgers so we can get at them headers
158-
const currentTime = getCurrentServerTime(this.serverURL.hostname());
158+
const serverKey = this.serverURL.port()
159+
? `${this.serverURL.hostname()}:${this.serverURL.port()}`
160+
: this.serverURL.hostname();
161+
const currentTime = getCurrentServerTime(serverKey);
159162

160163
if (currentTime) {
161164
return {

src/horizon/server_api.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,7 @@ export namespace ServerApi {
116116
sponsor?: string;
117117
num_sponsoring: number;
118118
num_sponsored: number;
119+
transactions: CallCollectionFunction<TransactionRecord>;
119120
effects: CallCollectionFunction<EffectRecord>;
120121
offers: CallCollectionFunction<OfferRecord>;
121122
operations: CallCollectionFunction<OperationRecord>;

0 commit comments

Comments
 (0)