Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
24 changes: 20 additions & 4 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,8 +67,6 @@
`Int128`/`Int256`/`Uint128`/`Uint256` (e.g., `new Int128(...values)` instead
of `new Int128(values)`), fixing nested-array behavior in the `LargeInt` base
class.
- `RevokeSignerSponsorship.signer` no longer accepts `Ed25519SignedPayload`
signer variants.
- `SetOptions` result signer conditional type simplified — the generic parameter
is used directly instead of the old complex conditional mapping.
- `SetOptions.clearFlags`/`setFlags` widened from `AuthFlag` to
Expand All @@ -79,7 +77,6 @@
- `checkUnsignedIntValue` string-to-number conversion changed from
`parseFloat()` to `Number()`. Strings with trailing non-numeric characters
(e.g., `"123abc"`) that previously silently succeeded will now be rejected.

- `authorizeInvocation()` now takes a single `AuthorizeInvocationParams`
object instead of positional arguments. Callers must switch from
`authorizeInvocation(signer, validUntilLedgerSeq, invocation, publicKey,
Expand Down Expand Up @@ -113,7 +110,15 @@
- `Memo.return()` type declaration now correctly accepts `Buffer | string`
(previously only `string` in the type declarations, though both were accepted
at runtime).
- `Operation.createStellarAssetContract` and `Operation.uploadContractWasm` now accept an optional auth field in TypeScript, matching existing runtime behavior.
- `Operation.createStellarAssetContract` and `Operation.uploadContractWasm` now
accept an optional `auth` field. The auth array is now passed through to the
host function call at runtime (previously it was silently omitted).
- `revokeSignerSponsorship` now supports `ed25519SignedPayload` signers via
`opts.signer.ed25519SignedPayload`. The corresponding
`Ed25519SignedPayloadSignerOpt` has been added to the `RevokeSignerOpts` type.
- `Operation.fromXDRObject` now decodes `ed25519SignedPayload` signer keys,
returning a `signer.ed25519SignedPayload` StrKey string. Previously, this
signer type fell through to the default case and threw an error.

### Fixed

Expand Down Expand Up @@ -163,6 +168,17 @@
directly instead of dynamic string construction.
- `TransactionBase.signHashX` error message typo fixed
("cannnot" → "cannot").
- `nativeToScVal` and `scvSortedMap` map key sorting now uses
locale-independent comparison (`<`/`>`) instead of `localeCompare`, ensuring
deterministic key order regardless of the runtime locale.
- `Soroban.formatTokenAmount` now correctly handles negative amounts instead of
producing malformed output (e.g., `-1000000` with 7 decimals now returns
`"-0.1"` instead of `"-1000000"`).
- `setTrustLineFlags` now throws `TypeError` when flag values are not booleans,
instead of silently ignoring non-boolean truthy/falsy values.
- `TransactionBuilder.addSacTransferOperation` now supports muxed (M...)
addresses for the destination and source. Previously, passing muxed addresses
caused `Keypair.fromPublicKey` to throw.

## [`v14.1.0`](https://github.com/stellar/js-stellar-base/compare/v14.0.4...v14.1.0):

Expand Down
8 changes: 8 additions & 0 deletions src/operation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -575,6 +575,14 @@ function convertXDRSignerKeyToObject(
attrs.sha256Hash = signerKey.hashX().toString("hex");
break;
}
case xdr.SignerKeyType.signerKeyTypeEd25519SignedPayload().name: {
const signedPayload = signerKey.ed25519SignedPayload();

attrs.ed25519SignedPayload = StrKey.encodeSignedPayload(
signedPayload.toXDR(),
);
break;
}
default: {
throw new Error(`Unknown signerKey: ${signerKey.switch().name}`);
}
Expand Down
14 changes: 14 additions & 0 deletions src/operations/revoke_sponsorship.ts
Original file line number Diff line number Diff line change
Expand Up @@ -274,6 +274,7 @@ export function revokeLiquidityPoolSponsorship(
* @param opts.signer.ed25519PublicKey - (optional) The ed25519 public key of the signer.
* @param opts.signer.sha256Hash - (optional) sha256 hash (Buffer or hex string).
* @param opts.signer.preAuthTx - (optional) Hash (Buffer or hex string) of transaction.
* @param opts.signer.ed25519SignedPayload - (optional) Signed payload signer (StrKey P... address).
* @param opts.source - The source account for the operation. Defaults to the transaction's source account.
*
* @example
Expand Down Expand Up @@ -327,6 +328,19 @@ export function revokeSignerSponsorship(
}

key = xdr.SignerKey.signerKeyTypeHashX(buffer);
} else if (opts.signer.ed25519SignedPayload) {
if (!StrKey.isValidSignedPayload(opts.signer.ed25519SignedPayload)) {
throw new Error("signer.ed25519SignedPayload is invalid.");
}

const rawPayload = StrKey.decodeSignedPayload(
opts.signer.ed25519SignedPayload,
);

const signedPayloadXdr =
xdr.SignerKeyEd25519SignedPayload.fromXDR(rawPayload);

key = xdr.SignerKey.signerKeyTypeEd25519SignedPayload(signedPayloadXdr);
Comment thread
quietbits marked this conversation as resolved.
} else {
throw new Error("signer is invalid");
}
Expand Down
6 changes: 6 additions & 0 deletions src/operations/set_trustline_flags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,12 @@ export function setTrustLineFlags(
throw new Error(`Invalid flag name: ${flagName}`);
}

if (typeof flagValue !== "boolean" && typeof flagValue !== "undefined") {
throw new TypeError(
`opts.flags.${flagName} must be a boolean (got ${typeof flagValue})`,
);
}

if (flagValue === true) {
setFlag |= bit.value;
} else if (flagValue === false) {
Expand Down
1 change: 1 addition & 0 deletions src/operations/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,7 @@ export interface RevokeLiquidityPoolSponsorshipOpts {

export type RevokeSignerOpts =
| Ed25519PublicKeySignerOpt
| Ed25519SignedPayloadSignerOpt

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Nice catch I assumed revoke sponsor did not apply for signed payloads

| PreAuthTxSignerOpt
| Sha256HashSignerOpt;

Expand Down
9 changes: 6 additions & 3 deletions src/scval.ts
Original file line number Diff line number Diff line change
Expand Up @@ -235,7 +235,7 @@ export function nativeToScVal(
// The Soroban runtime expects maps to have their keys in sorted
// order, so let's do that here as part of the conversion to prevent
// confusing error messages on execution.
.sort(([key1], [key2]) => key1.localeCompare(key2))
.sort(([key1], [key2]) => (key1 < key2 ? -1 : key1 > key2 ? 1 : 0))
.map(([k, v]) => {
// the type can be specified with an entry for the key and the value,
// e.g. val = { 'hello': 1 } and opts.type = { hello: [ 'symbol',
Expand Down Expand Up @@ -477,8 +477,11 @@ export function scvSortedMap(items: xdr.ScMapEntry[]): xdr.ScVal {
if (nativeA === nativeB) return 0;
return nativeA < (nativeB as bigint | number) ? -1 : 1;

default:
return nativeA.toString().localeCompare(nativeB.toString());
default: {
const strA = nativeA.toString();
const strB = nativeB.toString();
return strA < strB ? -1 : strA > strB ? 1 : 0;
}
}
});

Expand Down
8 changes: 6 additions & 2 deletions src/soroban.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,9 @@ export class Soroban {
throw new TypeError("No decimals are allowed");
}

let formatted = amount;
const negative = amount.startsWith("-");
let formatted = negative ? amount.slice(1) : amount;

if (decimals > 0) {
if (decimals > formatted.length) {
formatted = ["0", formatted.toString().padStart(decimals, "0")].join(
Expand All @@ -35,10 +37,12 @@ export class Soroban {
}
}

return formatted
formatted = formatted
.replace(/(\.\d*?)0+$/, "$1") // strip trailing zeroes
.replace(/\.$/, ".0") // but keep at least one
.replace(/^\./, "0."); // and a leading one

return negative ? `-${formatted}` : formatted;
}

/**
Expand Down
32 changes: 24 additions & 8 deletions src/transaction_builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,10 @@ import xdr from "./xdr.js";

import { Account } from "./account.js";
import { MuxedAccount } from "./muxed_account.js";
import { decodeAddressToMuxedAccount } from "./util/decode_encode_muxed_account.js";
import {
decodeAddressToMuxedAccount,
extractBaseAddress,
} from "./util/decode_encode_muxed_account.js";

import { Transaction } from "./transaction.js";
import { FeeBumpTransaction } from "./fee_bump_transaction.js";
Expand Down Expand Up @@ -688,7 +691,15 @@ export class TransactionBuilder {
}
}

if (destination === this.source.accountId()) {
// Resolve M... muxed addresses to their underlying G... address for
// ledger key construction (Keypair.fromPublicKey only accepts G... keys).
const destinationBaseAddress = isDestinationContract
? destination
: extractBaseAddress(destination);

if (
destinationBaseAddress === extractBaseAddress(this.source.accountId())
) {
Comment thread
quietbits marked this conversation as resolved.
throw new Error("Destination cannot be the same as the source account.");
}

Expand All @@ -701,6 +712,7 @@ export class TransactionBuilder {
const contractId = asset.contractId(this.networkPassphrase);
const functionName = "transfer";
const source = this.source.accountId();
const sourceBaseAddress = extractBaseAddress(source);
const args = [
nativeToScVal(source, { type: "address" }),
nativeToScVal(destination, { type: "address" }),
Expand Down Expand Up @@ -770,15 +782,19 @@ export class TransactionBuilder {
footprint.readWrite().push(
xdr.LedgerKey.account(
new xdr.LedgerKeyAccount({
accountId: Keypair.fromPublicKey(destination).xdrPublicKey(),
accountId: Keypair.fromPublicKey(
destinationBaseAddress,
).xdrPublicKey(),
}),
),
);
} else if (asset.getIssuer() !== destination) {
} else if (asset.getIssuer() !== destinationBaseAddress) {
footprint.readWrite().push(
xdr.LedgerKey.trustline(
new xdr.LedgerKeyTrustLine({
accountId: Keypair.fromPublicKey(destination).xdrPublicKey(),
accountId: Keypair.fromPublicKey(
destinationBaseAddress,
).xdrPublicKey(),
asset: asset.toTrustLineXDRObject(),
}),
),
Expand All @@ -790,15 +806,15 @@ export class TransactionBuilder {
footprint.readWrite().push(
xdr.LedgerKey.account(
new xdr.LedgerKeyAccount({
accountId: Keypair.fromPublicKey(source).xdrPublicKey(),
accountId: Keypair.fromPublicKey(sourceBaseAddress).xdrPublicKey(),
}),
),
);
} else if (asset.getIssuer() !== source) {
} else if (asset.getIssuer() !== sourceBaseAddress) {
footprint.readWrite().push(
xdr.LedgerKey.trustline(
new xdr.LedgerKeyTrustLine({
accountId: Keypair.fromPublicKey(source).xdrPublicKey(),
accountId: Keypair.fromPublicKey(sourceBaseAddress).xdrPublicKey(),
asset: asset.toTrustLineXDRObject(),
}),
),
Expand Down
73 changes: 73 additions & 0 deletions test/unit/operations/revoke_sponsorship.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import { describe, it, expect } from "vitest";
import { Operation } from "../../../src/operation.js";
import { Asset } from "../../../src/asset.js";
import { LiquidityPoolId } from "../../../src/liquidity_pool_id.js";
import { Keypair } from "../../../src/keypair.js";
import { StrKey } from "../../../src/strkey.js";
import { hash } from "../../../src/hashing.js";
import xdr from "../../../src/xdr.js";
import {
Expand Down Expand Up @@ -428,4 +430,75 @@ describe("Operation.revokeSignerSponsorship()", () => {
const roundtripped = xdr.Operation.fromXDR(hex, "hex");
expect(roundtripped.body().switch().name).toBe("revokeSponsorship");
});

it("deserializes a revokeSignerSponsorship with an ed25519SignedPayload signer", () => {
// Build the XDR operation manually to test the deserialization path
const kp = Keypair.random();
const payload = Buffer.alloc(10, 0xab);
const signedPayload = new xdr.SignerKeyEd25519SignedPayload({
ed25519: kp.rawPublicKey(),
payload,
});
const signerKey =
xdr.SignerKey.signerKeyTypeEd25519SignedPayload(signedPayload);

const revokeSigner = new xdr.RevokeSponsorshipOpSigner({
accountId: kp.xdrAccountId(),
signerKey,
});
const revokeOp =
xdr.RevokeSponsorshipOp.revokeSponsorshipSigner(revokeSigner);
const opBody = xdr.OperationBody.revokeSponsorship(revokeOp);
const xdrOp = new xdr.Operation({ sourceAccount: null, body: opBody });

const obj = expectOperationType(
Operation.fromXDRObject(xdrOp),
"revokeSignerSponsorship",
);
const decodedSigner = expectObjectWithProperty(
obj.signer,
"ed25519SignedPayload",
);

// The encoded signed payload should be a valid StrKey P... address
expect(
StrKey.isValidSignedPayload(decodedSigner.ed25519SignedPayload),
).toBe(true);
});

it("creates a revokeSignerSponsorshipOp with an ed25519SignedPayload signer", () => {
// Build a valid signed payload StrKey from a keypair + payload
const kp = Keypair.random();
const payload = Buffer.alloc(10, 0xab);
const signedPayloadXdr = new xdr.SignerKeyEd25519SignedPayload({
ed25519: kp.rawPublicKey(),
payload,
});
const encodedPayload = StrKey.encodeSignedPayload(signedPayloadXdr.toXDR());

const signer = { ed25519SignedPayload: encodedPayload };
const op = Operation.revokeSignerSponsorship({ account, signer });
const operation = xdr.Operation.fromXDR(op.toXDR("hex"), "hex");
const obj = expectOperationType(
Operation.fromXDRObject(operation),
"revokeSignerSponsorship",
);
const decodedSigner = expectObjectWithProperty(
obj.signer,
"ed25519SignedPayload",
);

expect(operation.body().switch().name).toBe("revokeSponsorship");
expect(obj.account).toBe(account);
expect(decodedSigner.ed25519SignedPayload).toBe(encodedPayload);
});

it("fails with an invalid ed25519SignedPayload signer", () => {
expect(() =>
Operation.revokeSignerSponsorship({
account,
signer: { ed25519SignedPayload: "PBAD" },
}),
).toThrow(/signer\.ed25519SignedPayload is invalid/);
});
});
30 changes: 30 additions & 0 deletions test/unit/operations/set_trustline_flags.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,36 @@ describe("Operation.setTrustLineFlags()", () => {
).toThrow(/Source address is invalid/);
});

it("throws when flag value is truthy but not boolean true", () => {
expect(() =>
Operation.setTrustLineFlags({
trustor: account,
asset,
flags: { authorized: 1 } as unknown as { authorized?: boolean },
}),
).toThrow();
});

it("throws when flag value is falsy but not boolean false", () => {
expect(() =>
Operation.setTrustLineFlags({
trustor: account,
asset,
flags: { authorized: 0 } as unknown as { authorized?: boolean },
}),
).toThrow();
});

it("throws when flag value is a string", () => {
expect(() =>
Operation.setTrustLineFlags({
trustor: account,
asset,
flags: { authorized: "true" } as unknown as { authorized?: boolean },
}),
).toThrow();
});

it("roundtrips through XDR hex encoding", () => {
const op = Operation.setTrustLineFlags({
trustor: account,
Expand Down
Loading
Loading