diff --git a/CHANGELOG.md b/CHANGELOG.md index 7c2395ec..9b7be545 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 @@ -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, @@ -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 @@ -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): diff --git a/src/operation.ts b/src/operation.ts index b105689a..e15dd817 100644 --- a/src/operation.ts +++ b/src/operation.ts @@ -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}`); } diff --git a/src/operations/revoke_sponsorship.ts b/src/operations/revoke_sponsorship.ts index d15a7ffb..82530ee8 100644 --- a/src/operations/revoke_sponsorship.ts +++ b/src/operations/revoke_sponsorship.ts @@ -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 @@ -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); } else { throw new Error("signer is invalid"); } diff --git a/src/operations/set_trustline_flags.ts b/src/operations/set_trustline_flags.ts index 602d845d..bd742403 100644 --- a/src/operations/set_trustline_flags.ts +++ b/src/operations/set_trustline_flags.ts @@ -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) { diff --git a/src/operations/types.ts b/src/operations/types.ts index 51d023fb..87c047e8 100644 --- a/src/operations/types.ts +++ b/src/operations/types.ts @@ -219,6 +219,7 @@ export interface RevokeLiquidityPoolSponsorshipOpts { export type RevokeSignerOpts = | Ed25519PublicKeySignerOpt + | Ed25519SignedPayloadSignerOpt | PreAuthTxSignerOpt | Sha256HashSignerOpt; diff --git a/src/scval.ts b/src/scval.ts index b5fe7170..f9d1c57c 100644 --- a/src/scval.ts +++ b/src/scval.ts @@ -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', @@ -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; + } } }); diff --git a/src/soroban.ts b/src/soroban.ts index 3fb5b321..dfed87d3 100644 --- a/src/soroban.ts +++ b/src/soroban.ts @@ -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( @@ -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; } /** diff --git a/src/transaction_builder.ts b/src/transaction_builder.ts index 165400c9..604471e9 100644 --- a/src/transaction_builder.ts +++ b/src/transaction_builder.ts @@ -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"; @@ -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()) + ) { throw new Error("Destination cannot be the same as the source account."); } @@ -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" }), @@ -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(), }), ), @@ -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(), }), ), diff --git a/test/unit/operations/revoke_sponsorship.test.ts b/test/unit/operations/revoke_sponsorship.test.ts index 051b1707..ec850158 100644 --- a/test/unit/operations/revoke_sponsorship.test.ts +++ b/test/unit/operations/revoke_sponsorship.test.ts @@ -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 { @@ -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/); + }); }); diff --git a/test/unit/operations/set_trustline_flags.test.ts b/test/unit/operations/set_trustline_flags.test.ts index a272df83..282de1c4 100644 --- a/test/unit/operations/set_trustline_flags.test.ts +++ b/test/unit/operations/set_trustline_flags.test.ts @@ -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, diff --git a/test/unit/scval.test.ts b/test/unit/scval.test.ts index ee1ebe2c..6a6177d8 100644 --- a/test/unit/scval.test.ts +++ b/test/unit/scval.test.ts @@ -958,6 +958,17 @@ describe("nativeToScVal", () => { expect(entries[2].key().value()).toBe("z"); }); + it("sorts mixed-case keys by codepoint order, not locale order", () => { + // Codepoint order: 'B' (66) < '_' (95) < 'a' (97) + // localeCompare would give: _key, admin, Balance (case-insensitive) + // Correct byte order: Balance, _key, admin + const scv = nativeToScVal({ admin: 1, _key: 2, Balance: 3 }); + const entries = scv.value() as any[]; + expect(entries[0].key().value()).toBe("Balance"); + expect(entries[1].key().value()).toBe("_key"); + expect(entries[2].key().value()).toBe("admin"); + }); + it("handles empty object", () => { const scv = nativeToScVal({}); expect(scv.switch().name).toBe("scvMap"); @@ -1562,6 +1573,31 @@ describe("scvSortedMap", () => { expect(result[0].key().value()).toBe("a"); expect(result[1].key().value()).toBe("b"); }); + + it("sorts mixed-case string keys by codepoint order, not locale order", () => { + // Codepoint order: 'A' (65) < 'I' (73) < '_' (95) < 'a' (97) < 'i' (105) + // localeCompare would sort case-insensitively: _admin, Admin, balance + // Correct byte order: Admin, _admin, balance + const entries = [ + new xdr.ScMapEntry({ + key: xdr.ScVal.scvSymbol("balance"), + val: xdr.ScVal.scvU32(3), + }), + new xdr.ScMapEntry({ + key: xdr.ScVal.scvSymbol("Admin"), + val: xdr.ScVal.scvU32(1), + }), + new xdr.ScMapEntry({ + key: xdr.ScVal.scvSymbol("_admin"), + val: xdr.ScVal.scvU32(2), + }), + ]; + const sorted = scvSortedMap(entries); + const result = sorted.value() as any[]; + expect(result[0].key().value()).toBe("Admin"); + expect(result[1].key().value()).toBe("_admin"); + expect(result[2].key().value()).toBe("balance"); + }); }); // --------------------------------------------------------------------------- diff --git a/test/unit/soroban.test.ts b/test/unit/soroban.test.ts index 11a7184e..f0e4aca5 100644 --- a/test/unit/soroban.test.ts +++ b/test/unit/soroban.test.ts @@ -52,6 +52,13 @@ describe("Soroban", () => { it("handles negative amounts", () => { expect(Soroban.formatTokenAmount("-1000", 3)).toBe("-1.0"); }); + + it("handles negative amounts requiring zero-padding", () => { + expect(Soroban.formatTokenAmount("-1", 3)).toBe("-0.001"); + expect(Soroban.formatTokenAmount("-1", 1)).toBe("-0.1"); + expect(Soroban.formatTokenAmount("-123", 4)).toBe("-0.0123"); + expect(Soroban.formatTokenAmount("-123", 5)).toBe("-0.00123"); + }); }); describe("parseTokenAmount", () => { diff --git a/test/unit/transaction_builder.test.ts b/test/unit/transaction_builder.test.ts index a330f63d..71f1bc27 100644 --- a/test/unit/transaction_builder.test.ts +++ b/test/unit/transaction_builder.test.ts @@ -2255,4 +2255,74 @@ describe("addSacTransferOperation with invalid destination", () => { ); }).toThrow("networkPassphrase must be set to add a SAC transfer operation"); }); + + it("succeeds with a muxed (M...) destination for native asset transfer", () => { + const destKp = Keypair.random(); + const muxedDest = StrKey.encodeMed25519PublicKey( + Buffer.concat([ + StrKey.decodeEd25519PublicKey(destKp.publicKey()), + Buffer.alloc(8), + ]), + ); + + expect(() => { + new TransactionBuilder(source, { + fee: "100", + networkPassphrase, + timebounds: { minTime: 0, maxTime: 0 }, + }) + .addSacTransferOperation(muxedDest, Asset.native(), "10000000") + .setTimeout(TimeoutInfinite) + .build(); + }).not.toThrow(); + }); + + it("succeeds with a muxed (M...) destination for non-native asset transfer", () => { + const destKp = Keypair.random(); + const muxedDest = StrKey.encodeMed25519PublicKey( + Buffer.concat([ + StrKey.decodeEd25519PublicKey(destKp.publicKey()), + Buffer.alloc(8), + ]), + ); + const asset = new Asset( + "USDC", + "GDJJRRMBK4IWLEPJGIE6SXD2LP7REGZODU7WDC3I2D6MR37F4XSHBKX2", + ); + + expect(() => { + new TransactionBuilder(source, { + fee: "100", + networkPassphrase, + timebounds: { minTime: 0, maxTime: 0 }, + }) + .addSacTransferOperation(muxedDest, asset, "10000000") + .setTimeout(TimeoutInfinite) + .build(); + }).not.toThrow(); + }); + + it("succeeds with a MuxedAccount source for native asset transfer", () => { + const muxedSource = MuxedAccount.fromAddress( + StrKey.encodeMed25519PublicKey( + Buffer.concat([ + StrKey.decodeEd25519PublicKey(source.accountId()), + Buffer.alloc(8), + ]), + ), + source.sequenceNumber(), + ); + const destKp = Keypair.random(); + + expect(() => { + new TransactionBuilder(muxedSource, { + fee: "100", + networkPassphrase, + timebounds: { minTime: 0, maxTime: 0 }, + }) + .addSacTransferOperation(destKp.publicKey(), Asset.native(), "10000000") + .setTimeout(TimeoutInfinite) + .build(); + }).not.toThrow(); + }); });