From 751580807335e9d8aa90d74b1dbf38221665c6e4 Mon Sep 17 00:00:00 2001 From: Aristides Staffieri Date: Fri, 12 Jun 2026 11:20:47 -0600 Subject: [PATCH 1/4] feat: surface CAP-71 bound signer address in WalletKit sign flows Bump @stellar/stellar-sdk to 16.0.0-rc.1 (Protocol 27) and surface the signer address bound into Soroban authorization entries across the WalletKit sign_auth_entry and sign_xdr review screens. - Normalize both authorization preimage arms (legacy envelopeTypeSorobanAuthorization and the CAP-71 envelopeTypeSorobanAuthorizationWithAddress) via normalizeAuthPreimage; display the bound signer address in DappAuthEntryDisplay and as a "Signer address" row in the transaction Authorizations view - Add getAddressCredentials/getAuthEntryBoundAddress soroban helpers covering the address, addressV2, and addressWithDelegates credential arms - Add validateAuthEntryAddress: reject sign_auth_entry requests whose account-bound address differs from the active wallet, enforced both pre-UI (WalletKitProvider) and at approval time (walletKitUtil) - Fix bignumber.js v11 resolution in metro.config.js: SDK 16 pulls in bignumber v11, whose react-native field points at a browser-globals UMD build with no module.exports, crashing the app on launch; rewrite the resolved path to dist/bignumber.cjs - Add a withAddress preimage variant to the mock-dapp and cover the new helpers/validation in tests; add en/pt translations --- __tests__/helpers/soroban.test.ts | 70 +++- __tests__/helpers/stellar.test.ts | 59 +++- __tests__/helpers/stellarSdkV15.test.ts | 60 ++++ __tests__/helpers/walletKitValidation.test.ts | 303 ++++++++++++++++-- jest.config.js | 8 + metro.config.js | 22 +- mock-dapp/src/routes.ts | 73 ++++- package.json | 4 +- .../components/KeyVal.tsx | 50 +-- .../components/Operations.tsx | 24 +- .../SignTransactionAuthorizations.tsx | 31 +- .../SignTransactionOperationDetails.tsx | 6 +- .../hooks/useSignTransactionDetails.ts | 8 +- .../SignTransactionDetails/types/index.ts | 16 +- .../WalletKit/DappAuthEntryDisplay.tsx | 68 +++- src/helpers/soroban.ts | 46 ++- src/helpers/stellar.ts | 24 +- src/helpers/walletKitUtil.ts | 8 +- src/helpers/walletKitValidation.ts | 127 +++++++- src/i18n/locales/en/translations.json | 4 +- src/i18n/locales/pt/translations.json | 4 +- src/providers/WalletKitProvider.tsx | 34 ++ yarn.lock | 105 +++++- 23 files changed, 1025 insertions(+), 129 deletions(-) diff --git a/__tests__/helpers/soroban.test.ts b/__tests__/helpers/soroban.test.ts index 46bf72351..c051a318b 100644 --- a/__tests__/helpers/soroban.test.ts +++ b/__tests__/helpers/soroban.test.ts @@ -1,4 +1,4 @@ -import { xdr } from "@stellar/stellar-sdk"; +import { Address, xdr } from "@stellar/stellar-sdk"; import { BigNumber } from "bignumber.js"; import { ClassicBalance, @@ -9,6 +9,7 @@ import { import { computeTotalFeeXlm, getArgsForTokenInvocation, + getAuthEntryBoundAddress, SorobanTokenInterface, addressToString, isSorobanTransaction, @@ -236,6 +237,73 @@ describe("soroban helpers", () => { }); }); + describe("getAuthEntryBoundAddress", () => { + const BOUND_ADDRESS = + "GDQNY3PBOJOKYZSRMK2S7LHHGWZIUISD4QORETLMXEWXBI7KFZZMKTL3"; + + const rootInvocation = () => + new xdr.SorobanAuthorizedInvocation({ + function: + xdr.SorobanAuthorizedFunction.sorobanAuthorizedFunctionTypeContractFn( + new xdr.InvokeContractArgs({ + contractAddress: new Address( + "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABSC4", + ).toScAddress(), + functionName: "transfer", + args: [], + }), + ), + subInvocations: [], + }); + + const addressCreds = () => + new xdr.SorobanAddressCredentials({ + address: new Address(BOUND_ADDRESS).toScAddress(), + nonce: xdr.Int64.fromString("1") as xdr.Int64, + signatureExpirationLedger: 999999, + signature: xdr.ScVal.scvVoid(), + }); + + const buildEntry = (credentials: xdr.SorobanCredentials) => + new xdr.SorobanAuthorizationEntry({ + credentials, + rootInvocation: rootInvocation(), + }); + + it("returns undefined for source-account credentials", () => { + const entry = buildEntry( + xdr.SorobanCredentials.sorobanCredentialsSourceAccount(), + ); + expect(getAuthEntryBoundAddress(entry)).toBeUndefined(); + }); + + it("returns the bound address for ADDRESS credentials", () => { + const entry = buildEntry( + xdr.SorobanCredentials.sorobanCredentialsAddress(addressCreds()), + ); + expect(getAuthEntryBoundAddress(entry)).toBe(BOUND_ADDRESS); + }); + + it("returns the bound address for ADDRESS_V2 credentials", () => { + const entry = buildEntry( + xdr.SorobanCredentials.sorobanCredentialsAddressV2(addressCreds()), + ); + expect(getAuthEntryBoundAddress(entry)).toBe(BOUND_ADDRESS); + }); + + it("returns the top-level bound address for ADDRESS_WITH_DELEGATES credentials", () => { + const entry = buildEntry( + xdr.SorobanCredentials.sorobanCredentialsAddressWithDelegates( + new xdr.SorobanAddressCredentialsWithDelegates({ + addressCredentials: addressCreds(), + delegates: [], + }), + ), + ); + expect(getAuthEntryBoundAddress(entry)).toBe(BOUND_ADDRESS); + }); + }); + describe("computeTotalFeeXlm", () => { const CLASSIC_FEE = "0.00001"; diff --git a/__tests__/helpers/stellar.test.ts b/__tests__/helpers/stellar.test.ts index 804920940..b20831291 100644 --- a/__tests__/helpers/stellar.test.ts +++ b/__tests__/helpers/stellar.test.ts @@ -768,6 +768,40 @@ describe("Stellar helpers", () => { ).toXDR("base64"); }; + /** + * Build a CAP-71 envelopeTypeSorobanAuthorizationWithAddress preimage — + * the arm dapps on protocol 27 send for ADDRESS_V2 credentials. Bound to + * the signing account so the signer's address check passes. + */ + const buildTestWithAddressPreimage = ( + network: string = Networks.TESTNET, + nonce: string = "1234567890", + address: string = Keypair.fromSecret(seed).publicKey(), + ): string => { + const invocation = new xdr.SorobanAuthorizedInvocation({ + function: + xdr.SorobanAuthorizedFunction.sorobanAuthorizedFunctionTypeContractFn( + new xdr.InvokeContractArgs({ + contractAddress: new Address( + "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABSC4", + ).toScAddress(), + functionName: "test_function", + args: [], + }), + ), + subInvocations: [], + }); + return xdr.HashIdPreimage.envelopeTypeSorobanAuthorizationWithAddress( + new xdr.HashIdPreimageSorobanAuthorizationWithAddress({ + networkId: hash(Buffer.from(network)), + nonce: xdr.Int64.fromString(nonce) as xdr.Int64, + signatureExpirationLedger: 999999, + address: new Address(address).toScAddress(), + invocation, + }), + ).toXDR("base64"); + }; + describe("signAuthEntry", () => { it("should return { signedAuthEntry, signerAddress } for a valid preimage", () => { const preimageXdr = buildTestPreimage(); @@ -831,9 +865,32 @@ describe("Stellar helpers", () => { it("should throw for invalid XDR (not a HashIdPreimage)", () => { // signAuthEntry now validates that the input is a valid - // HashIdPreimage.envelopeTypeSorobanAuthorization before signing. + // Soroban authorization HashIdPreimage before signing. expect(() => signAuthEntry("not-valid-xdr!!", seed)).toThrow(); }); + + it("should sign a CAP-71 withAddress preimage with the same arm-agnostic payload", () => { + const keypair = Keypair.fromSecret(seed); + const preimageXdr = buildTestWithAddressPreimage(); + const result = signAuthEntry(preimageXdr, seed); + + // SEP-43: signature is over hash(raw_preimage_bytes) regardless of arm + const sigBytes = Buffer.from(result.signedAuthEntry, "base64"); + expect(sigBytes.length).toBe(64); + const expectedPayload = hash(Buffer.from(preimageXdr, "base64")); + expect(keypair.verify(expectedPayload, sigBytes)).toBe(true); + expect(result.signerAddress).toBe(keypair.publicKey()); + }); + + it("should throw for a CAP-71 withAddress preimage bound to a different account", () => { + // Bound to an unrelated account, not the signer. + const preimageXdr = buildTestWithAddressPreimage( + Networks.TESTNET, + "1234567890", + "GDQNY3PBOJOKYZSRMK2S7LHHGWZIUISD4QORETLMXEWXBI7KFZZMKTL3", + ); + expect(() => signAuthEntry(preimageXdr, seed)).toThrow(); + }); }); }); }); diff --git a/__tests__/helpers/stellarSdkV15.test.ts b/__tests__/helpers/stellarSdkV15.test.ts index ce150c5d4..1ad67f0dd 100644 --- a/__tests__/helpers/stellarSdkV15.test.ts +++ b/__tests__/helpers/stellarSdkV15.test.ts @@ -21,6 +21,7 @@ import { walkInvocationTree, xdr, } from "@stellar/stellar-sdk"; +import { getAuthEntryBoundAddress } from "helpers/soroban"; describe("Stellar SDK v15 compatibility", () => { const networkPassphrase = Networks.TESTNET; @@ -215,4 +216,63 @@ describe("Stellar SDK v15 compatibility", () => { expect(scAddr.switch().name).toBe("scAddressTypeAccount"); }); }); + + // ─────────────────────────────────────────────────────────────────────────── + // CAP-71 / Protocol 27 — ADDRESS_V2 credentials + // ─────────────────────────────────────────────────────────────────────────── + + describe("CAP-71 ADDRESS_V2 credentials (Protocol 27)", () => { + it("rootInvocation() works on an auth entry with ADDRESS_V2 credentials (transaction review path)", () => { + const invocation = new xdr.SorobanAuthorizedInvocation({ + function: + xdr.SorobanAuthorizedFunction.sorobanAuthorizedFunctionTypeContractFn( + new xdr.InvokeContractArgs({ + contractAddress: new Address( + "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABSC4", + ).toScAddress(), + functionName: "transfer", + args: [], + }), + ), + subInvocations: [], + }); + + const credentials = xdr.SorobanCredentials.sorobanCredentialsAddressV2( + new xdr.SorobanAddressCredentials({ + address: new Address(keypair.publicKey()).toScAddress(), + nonce: xdr.Int64.fromString("1") as xdr.Int64, + signatureExpirationLedger: 999999, + signature: xdr.ScVal.scvVoid(), + }), + ); + + const entry = new xdr.SorobanAuthorizationEntry({ + credentials, + rootInvocation: invocation, + }); + + // The signing-review screens only read rootInvocation() and never + // switch on credentials — this locks that assumption for ADDRESS_V2. + const roundtripped = xdr.SorobanAuthorizationEntry.fromXDR( + entry.toXDR("base64"), + "base64", + ); + const root = roundtripped.rootInvocation(); + expect(root).toBeInstanceOf(xdr.SorobanAuthorizedInvocation); + expect(root.function().contractFn().functionName().toString()).toBe( + "transfer", + ); + + // walkInvocationTree (used by getInvocationDetails) traverses it fine + const visited: string[] = []; + walkInvocationTree(root, (node) => { + visited.push(node.function().switch().name); + return true; + }); + expect(visited.length).toBe(1); + + // The transaction-review path surfaces the bound address per entry. + expect(getAuthEntryBoundAddress(roundtripped)).toBe(keypair.publicKey()); + }); + }); }); diff --git a/__tests__/helpers/walletKitValidation.test.ts b/__tests__/helpers/walletKitValidation.test.ts index 645a21490..8ecb0ca6b 100644 --- a/__tests__/helpers/walletKitValidation.test.ts +++ b/__tests__/helpers/walletKitValidation.test.ts @@ -1,7 +1,9 @@ -import { Address, hash, Networks, xdr } from "@stellar/stellar-sdk"; +import { Address, hash, Keypair, Networks, xdr } from "@stellar/stellar-sdk"; import { + normalizeAuthPreimage, parseAuthEntryPreimage, SIGN_MESSAGE_MAX_BYTES, + validateAuthEntryAddress, validateAuthEntryNetwork, validateSignAuthEntry, validateSignAuthEntryContent, @@ -14,14 +16,15 @@ import { // Test fixtures // ───────────────────────────────────────────────────────────────────────────── -/** - * Builds a valid base64 HashIdPreimage for the given network passphrase. - */ -const buildTestPreimage = ( - network: string = Networks.TESTNET, - nonce: string = "1234567890", -): string => { - const invocation = new xdr.SorobanAuthorizedInvocation({ +const TEST_SIGNER_ADDRESS = + "GDQNY3PBOJOKYZSRMK2S7LHHGWZIUISD4QORETLMXEWXBI7KFZZMKTL3"; + +// A different wallet account, used for address-mismatch assertions. +const OTHER_WALLET_ADDRESS = + "GBXFXNDLV4LSWA4VB7YIL5GBD7BVNR22SGBTDKMO2SBZZHDXSKZYCP7L"; + +const buildTestInvocation = (): xdr.SorobanAuthorizedInvocation => + new xdr.SorobanAuthorizedInvocation({ function: xdr.SorobanAuthorizedFunction.sorobanAuthorizedFunctionTypeContractFn( new xdr.InvokeContractArgs({ @@ -35,15 +38,50 @@ const buildTestPreimage = ( subInvocations: [], }); - return xdr.HashIdPreimage.envelopeTypeSorobanAuthorization( +/** + * Builds a valid base64 HashIdPreimage for the given network passphrase. + */ +const buildTestPreimage = ( + network: string = Networks.TESTNET, + nonce: string = "1234567890", +): string => + xdr.HashIdPreimage.envelopeTypeSorobanAuthorization( new xdr.HashIdPreimageSorobanAuthorization({ networkId: hash(Buffer.from(network)), nonce: xdr.Int64.fromString(nonce) as xdr.Int64, signatureExpirationLedger: 999999, - invocation, + invocation: buildTestInvocation(), + }), + ).toXDR("base64"); + +/** + * Builds a base64 CAP-71 envelopeTypeSorobanAuthorizationWithAddress preimage, + * the arm dapps on protocol 27 send for ADDRESS_V2 credentials. + */ +const buildTestWithAddressPreimage = ( + network: string = Networks.TESTNET, + nonce: string = "1234567890", + signerAddress: string = TEST_SIGNER_ADDRESS, +): string => + xdr.HashIdPreimage.envelopeTypeSorobanAuthorizationWithAddress( + new xdr.HashIdPreimageSorobanAuthorizationWithAddress({ + networkId: hash(Buffer.from(network)), + nonce: xdr.Int64.fromString(nonce) as xdr.Int64, + signatureExpirationLedger: 999999, + address: new Address(signerAddress).toScAddress(), + invocation: buildTestInvocation(), + }), + ).toXDR("base64"); + +/** Builds a real non-Soroban-authorization preimage (operation ID arm). */ +const buildNonSorobanPreimage = (): string => + xdr.HashIdPreimage.envelopeTypeOpId( + new xdr.HashIdPreimageOperationId({ + sourceAccount: Keypair.fromPublicKey(TEST_SIGNER_ADDRESS).xdrAccountId(), + seqNum: xdr.Int64.fromString("1") as xdr.Int64, + opNum: 0, }), ).toXDR("base64"); -}; // ───────────────────────────────────────────────────────────────────────────── // validateSignMessageContent @@ -236,6 +274,58 @@ describe("parseAuthEntryPreimage", () => { }); }); +// ───────────────────────────────────────────────────────────────────────────── +// normalizeAuthPreimage +// ───────────────────────────────────────────────────────────────────────────── + +describe("normalizeAuthPreimage", () => { + it("normalizes a legacy sorobanAuthorization preimage without an address", () => { + const preimage = xdr.HashIdPreimage.fromXDR(buildTestPreimage(), "base64"); + const normalized = normalizeAuthPreimage(preimage); + expect(normalized).not.toBeNull(); + expect(normalized?.address).toBeUndefined(); + expect( + normalized?.networkId.equals(hash(Buffer.from(Networks.TESTNET))), + ).toBe(true); + expect(normalized?.invocation).toBeInstanceOf( + xdr.SorobanAuthorizedInvocation, + ); + }); + + it("returns null for a non-Soroban-authorization preimage arm", () => { + const preimage = xdr.HashIdPreimage.fromXDR( + buildNonSorobanPreimage(), + "base64", + ); + expect(normalizeAuthPreimage(preimage)).toBeNull(); + }); + + it("returns null for a malformed preimage object", () => { + const malformed = { + sorobanAuthorization: () => { + throw new Error("Not a soroban preimage type"); + }, + } as unknown as xdr.HashIdPreimage; + expect(normalizeAuthPreimage(malformed)).toBeNull(); + }); + + it("normalizes a CAP-71 sorobanAuthorizationWithAddress preimage and surfaces the address", () => { + const preimage = xdr.HashIdPreimage.fromXDR( + buildTestWithAddressPreimage(), + "base64", + ); + const normalized = normalizeAuthPreimage(preimage); + expect(normalized).not.toBeNull(); + expect(normalized?.invocation).toBeInstanceOf( + xdr.SorobanAuthorizedInvocation, + ); + expect(normalized?.address).toBeDefined(); + expect(Address.fromScAddress(normalized!.address!).toString()).toBe( + TEST_SIGNER_ADDRESS, + ); + }); +}); + // ───────────────────────────────────────────────────────────────────────────── // validateAuthEntryNetwork // ───────────────────────────────────────────────────────────────────────────── @@ -299,6 +389,44 @@ describe("validateAuthEntryNetwork", () => { expect(result.errorKey).toBe(ValidationErrorKeys.INVALID_AUTH_ENTRY); } }); + + it("returns invalid auth entry error for a non-Soroban-authorization preimage arm", () => { + const preimage = xdr.HashIdPreimage.fromXDR( + buildNonSorobanPreimage(), + "base64", + ); + const result = validateAuthEntryNetwork(preimage, Networks.TESTNET); + expect(result.valid).toBe(false); + if (!result.valid) { + expect(result.errorKey).toBe(ValidationErrorKeys.INVALID_AUTH_ENTRY); + } + }); + + it("returns valid when a CAP-71 withAddress preimage networkId matches the wallet network", () => { + const preimage = xdr.HashIdPreimage.fromXDR( + buildTestWithAddressPreimage(Networks.TESTNET), + "base64", + ); + const result = validateAuthEntryNetwork(preimage, Networks.TESTNET); + expect(result.valid).toBe(true); + if (result.valid) { + expect(result.value).toBeInstanceOf(xdr.SorobanAuthorizedInvocation); + } + }); + + it("returns network mismatch error for a CAP-71 withAddress preimage on the wrong network", () => { + const preimage = xdr.HashIdPreimage.fromXDR( + buildTestWithAddressPreimage(Networks.PUBLIC), + "base64", + ); + const result = validateAuthEntryNetwork(preimage, Networks.TESTNET); + expect(result.valid).toBe(false); + if (!result.valid) { + expect(result.errorKey).toBe( + ValidationErrorKeys.AUTH_ENTRY_NETWORK_MISMATCH, + ); + } + }); }); // ───────────────────────────────────────────────────────────────────────────── @@ -308,7 +436,11 @@ describe("validateAuthEntryNetwork", () => { describe("validateSignAuthEntry", () => { it("returns valid preimage for a correct testnet entry", () => { const preimageXdr = buildTestPreimage(Networks.TESTNET); - const result = validateSignAuthEntry(preimageXdr, Networks.TESTNET); + const result = validateSignAuthEntry( + preimageXdr, + Networks.TESTNET, + TEST_SIGNER_ADDRESS, + ); expect(result.valid).toBe(true); if (result.valid) { expect(result.value).toBeInstanceOf(xdr.HashIdPreimage); @@ -316,7 +448,11 @@ describe("validateSignAuthEntry", () => { }); it("returns error when entryXdr is null", () => { - const result = validateSignAuthEntry(null, Networks.TESTNET); + const result = validateSignAuthEntry( + null, + Networks.TESTNET, + TEST_SIGNER_ADDRESS, + ); expect(result.valid).toBe(false); if (!result.valid) { expect(result.errorKey).toBe(ValidationErrorKeys.INVALID_AUTH_ENTRY); @@ -324,7 +460,11 @@ describe("validateSignAuthEntry", () => { }); it("returns error when entryXdr is empty string", () => { - const result = validateSignAuthEntry("", Networks.TESTNET); + const result = validateSignAuthEntry( + "", + Networks.TESTNET, + TEST_SIGNER_ADDRESS, + ); expect(result.valid).toBe(false); if (!result.valid) { expect(result.errorKey).toBe(ValidationErrorKeys.INVALID_AUTH_ENTRY); @@ -332,7 +472,11 @@ describe("validateSignAuthEntry", () => { }); it("returns error when entryXdr is not valid base64 XDR", () => { - const result = validateSignAuthEntry("not-valid-xdr!!!", Networks.TESTNET); + const result = validateSignAuthEntry( + "not-valid-xdr!!!", + Networks.TESTNET, + TEST_SIGNER_ADDRESS, + ); expect(result.valid).toBe(false); if (!result.valid) { expect(result.errorKey).toBe(ValidationErrorKeys.INVALID_AUTH_ENTRY); @@ -341,7 +485,11 @@ describe("validateSignAuthEntry", () => { it("returns network mismatch error when preimage is for mainnet but wallet is on testnet", () => { const preimageXdr = buildTestPreimage(Networks.PUBLIC); - const result = validateSignAuthEntry(preimageXdr, Networks.TESTNET); + const result = validateSignAuthEntry( + preimageXdr, + Networks.TESTNET, + TEST_SIGNER_ADDRESS, + ); expect(result.valid).toBe(false); if (!result.valid) { expect(result.errorKey).toBe( @@ -352,7 +500,11 @@ describe("validateSignAuthEntry", () => { it("returns network mismatch error when preimage is for testnet but wallet is on mainnet", () => { const preimageXdr = buildTestPreimage(Networks.TESTNET); - const result = validateSignAuthEntry(preimageXdr, Networks.PUBLIC); + const result = validateSignAuthEntry( + preimageXdr, + Networks.PUBLIC, + TEST_SIGNER_ADDRESS, + ); expect(result.valid).toBe(false); if (!result.valid) { expect(result.errorKey).toBe( @@ -363,16 +515,127 @@ describe("validateSignAuthEntry", () => { it("returns valid preimage for valid mainnet entry", () => { const preimageXdr = buildTestPreimage(Networks.PUBLIC); - const result = validateSignAuthEntry(preimageXdr, Networks.PUBLIC); + const result = validateSignAuthEntry( + preimageXdr, + Networks.PUBLIC, + TEST_SIGNER_ADDRESS, + ); expect(result.valid).toBe(true); }); it("returns the same XDR preimage when re-serialized", () => { const preimageXdr = buildTestPreimage(Networks.TESTNET); - const result = validateSignAuthEntry(preimageXdr, Networks.TESTNET); + const result = validateSignAuthEntry( + preimageXdr, + Networks.TESTNET, + TEST_SIGNER_ADDRESS, + ); expect(result.valid).toBe(true); if (result.valid) { expect(result.value.toXDR("base64")).toBe(preimageXdr); } }); + + it("returns valid preimage for a CAP-71 withAddress entry bound to the wallet", () => { + const preimageXdr = buildTestWithAddressPreimage(Networks.TESTNET); + const result = validateSignAuthEntry( + preimageXdr, + Networks.TESTNET, + TEST_SIGNER_ADDRESS, + ); + expect(result.valid).toBe(true); + if (result.valid) { + expect(result.value.toXDR("base64")).toBe(preimageXdr); + } + }); + + it("returns address mismatch error for a CAP-71 withAddress entry bound to a different account", () => { + const preimageXdr = buildTestWithAddressPreimage(Networks.TESTNET); + const result = validateSignAuthEntry( + preimageXdr, + Networks.TESTNET, + OTHER_WALLET_ADDRESS, + ); + expect(result.valid).toBe(false); + if (!result.valid) { + expect(result.errorKey).toBe( + ValidationErrorKeys.AUTH_ENTRY_ADDRESS_MISMATCH, + ); + } + }); + + it("returns network mismatch error for a CAP-71 withAddress entry on the wrong network", () => { + const preimageXdr = buildTestWithAddressPreimage(Networks.PUBLIC); + const result = validateSignAuthEntry( + preimageXdr, + Networks.TESTNET, + TEST_SIGNER_ADDRESS, + ); + expect(result.valid).toBe(false); + if (!result.valid) { + expect(result.errorKey).toBe( + ValidationErrorKeys.AUTH_ENTRY_NETWORK_MISMATCH, + ); + } + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// validateAuthEntryAddress +// ───────────────────────────────────────────────────────────────────────────── + +describe("validateAuthEntryAddress", () => { + it("passes a legacy preimage through (no bound address to enforce)", () => { + const preimage = xdr.HashIdPreimage.fromXDR(buildTestPreimage(), "base64"); + const result = validateAuthEntryAddress(preimage, OTHER_WALLET_ADDRESS); + expect(result.valid).toBe(true); + }); + + it("returns valid when the bound address matches the wallet", () => { + const preimage = xdr.HashIdPreimage.fromXDR( + buildTestWithAddressPreimage(Networks.TESTNET, "1", TEST_SIGNER_ADDRESS), + "base64", + ); + const result = validateAuthEntryAddress(preimage, TEST_SIGNER_ADDRESS); + expect(result.valid).toBe(true); + }); + + it("returns address mismatch when the bound account differs from the wallet", () => { + const preimage = xdr.HashIdPreimage.fromXDR( + buildTestWithAddressPreimage(Networks.TESTNET, "1", TEST_SIGNER_ADDRESS), + "base64", + ); + const result = validateAuthEntryAddress(preimage, OTHER_WALLET_ADDRESS); + expect(result.valid).toBe(false); + if (!result.valid) { + expect(result.errorKey).toBe( + ValidationErrorKeys.AUTH_ENTRY_ADDRESS_MISMATCH, + ); + } + }); + + it("passes a contract-bound address through (cannot match a wallet key)", () => { + const preimage = xdr.HashIdPreimage.fromXDR( + buildTestWithAddressPreimage( + Networks.TESTNET, + "1", + "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABSC4", + ), + "base64", + ); + const result = validateAuthEntryAddress(preimage, OTHER_WALLET_ADDRESS); + expect(result.valid).toBe(true); + }); + + it("returns invalid for a non-Soroban-authorization preimage", () => { + const preimage = xdr.HashIdPreimage.fromXDR( + buildNonSorobanPreimage(), + "base64", + ); + const result = validateAuthEntryAddress(preimage, TEST_SIGNER_ADDRESS); + expect(result.valid).toBe(false); + if (!result.valid) { + expect(result.errorKey).toBe(ValidationErrorKeys.INVALID_AUTH_ENTRY); + } + }); }); diff --git a/jest.config.js b/jest.config.js index 86059e5dc..59e88e601 100644 --- a/jest.config.js +++ b/jest.config.js @@ -29,6 +29,14 @@ module.exports = { "react-native-worklets", "react-native-qrcode-svg", "stellar-hd-wallet", + // v16 ships ESM-only deps (@noble/*, smol-toml, uint8array-extras, + // eventsource), some nested under the SDK; transform these so Jest can + // load the SDK's CJS build. + "@stellar/stellar-sdk", + "@noble", + "smol-toml", + "uint8array-extras", + "eventsource", "react-native-config", "@react-native-cookies/cookies", "react-native-view-shot", diff --git a/metro.config.js b/metro.config.js index 7e3f0c949..f558acc31 100644 --- a/metro.config.js +++ b/metro.config.js @@ -2,6 +2,7 @@ /* eslint-disable import/no-unresolved */ /* eslint-disable @typescript-eslint/no-var-requires */ /* eslint-disable @typescript-eslint/no-unsafe-argument */ +/* eslint-disable @typescript-eslint/no-unsafe-member-access */ const { mergeConfig, getDefaultConfig } = require("@react-native/metro-config"); const { withSentryConfig } = require("@sentry/react-native/metro"); const { withNativeWind } = require("nativewind/metro"); @@ -63,7 +64,26 @@ const config = { }; } - return context.resolveRequest(context, moduleName, platform); + const resolved = context.resolveRequest(context, moduleName, platform); + + // bignumber.js v11 (pulled in by @stellar/stellar-sdk 16.x) ships a valid + // CommonJS entry at dist/bignumber.cjs, but its package.json "react-native" + // field redirects it to dist/bignumber.js — a browser-globals UMD build + // that sets no module.exports. Metro honors that field, so + // `require("bignumber.js")` resolves to {} and the SDK's + // `_interopDefault(...).default.clone()` throws. Rewrite that one broken + // target back to the real CJS build. (v9, used by stellar-base, is fine.) + if ( + moduleName === "bignumber.js" && + resolved?.filePath?.endsWith("/dist/bignumber.js") + ) { + return { + ...resolved, + filePath: resolved.filePath.replace(/\.js$/, ".cjs"), + }; + } + + return resolved; }, }, }; diff --git a/mock-dapp/src/routes.ts b/mock-dapp/src/routes.ts index 851f9be5d..a9fcc0c14 100644 --- a/mock-dapp/src/routes.ts +++ b/mock-dapp/src/routes.ts @@ -17,12 +17,17 @@ import { MockWalletConnectClient } from "./walletconnect"; * └── approve(user, dex, 50 XLM, expiry=500_000) * * Per SEP-43, the dApp constructs the HashIdPreimage and passes it to the - * wallet to hash-and-sign. No signer address is embedded in the preimage. + * wallet to hash-and-sign. The legacy variant embeds no signer address; the + * CAP-71 / Protocol 27 "withAddress" variant (sent by dapps using ADDRESS_V2 + * credentials) binds the signer address into the preimage. * * stellar-base v14: xdr.ScAddress.scAddressTypeContract accepts opaque * XDR byte-array types. Buffer is wire-compatible; cast via `as unknown`. */ -function generateAuthPreimageXdr(network: "testnet" | "pubnet"): string { +function generateAuthPreimageXdr( + network: "testnet" | "pubnet", + variant: "legacy" | "withAddress" = "legacy", +): string { const networkPassphrase = network === "pubnet" ? Networks.PUBLIC : Networks.TESTNET; @@ -100,6 +105,52 @@ function generateAuthPreimageXdr(network: "testnet" | "pubnet"): string { subInvocations: [usdcApprove, xlmApprove], }); + if (variant === "withAddress") { + // TODO(p27): drop the feature-detection cast once mock-dapp's + // @stellar/stellar-base is bumped to a CAP-71 / Protocol 27 release. + const xdrP27 = xdr as typeof xdr & { + HashIdPreimage: typeof xdr.HashIdPreimage & { + envelopeTypeSorobanAuthorizationWithAddress?: ( + value: unknown, + ) => xdr.HashIdPreimage; + }; + HashIdPreimageSorobanAuthorizationWithAddress?: new (fields: { + networkId: Buffer; + nonce: xdr.Int64; + signatureExpirationLedger: number; + address: xdr.ScAddress; + invocation: xdr.SorobanAuthorizedInvocation; + }) => unknown; + }; + + if ( + typeof xdrP27.HashIdPreimage + .envelopeTypeSorobanAuthorizationWithAddress !== "function" || + !xdrP27.HashIdPreimageSorobanAuthorizationWithAddress + ) { + throw new Error( + "withAddress preimages require a CAP-71 (Protocol 27) @stellar/stellar-base — bump mock-dapp's dependency", + ); + } + + return xdrP27.HashIdPreimage.envelopeTypeSorobanAuthorizationWithAddress( + new xdrP27.HashIdPreimageSorobanAuthorizationWithAddress({ + networkId: hash(Buffer.from(networkPassphrase)), + nonce: xdr.Int64.fromString(String(Date.now())), + signatureExpirationLedger: 999_999, + // Fixed test signer address (any 32 bytes encode to a valid G address) + address: xdr.ScAddress.scAddressTypeAccount( + xdr.PublicKey.publicKeyTypeEd25519( + Buffer.alloc(32, 0x42) as unknown as Parameters< + typeof xdr.PublicKey.publicKeyTypeEd25519 + >[0], + ), + ), + invocation: rootInvocation, + }), + ).toXDR("base64"); + } + return xdr.HashIdPreimage.envelopeTypeSorobanAuthorization( new xdr.HashIdPreimageSorobanAuthorization({ networkId: hash(Buffer.from(networkPassphrase)), @@ -463,11 +514,14 @@ export function createRoutes(wcClient: MockWalletConnectClient): Router { const id = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id; - const { entryXdr } = req.body as { + const { entryXdr, variant } = req.body as { entryXdr?: unknown; network?: unknown; + variant?: unknown; }; const network = parseNetwork((req.body as { network?: unknown }).network); + // CAP-71: "withAddress" generates the Protocol 27 preimage arm + const preimageVariant = variant === "withAddress" ? variant : "legacy"; const metadata = sessionMetadata.get(id); @@ -482,14 +536,13 @@ export function createRoutes(wcClient: MockWalletConnectClient): Router { }); } - // Use the supplied XDR (including empty string for testing). - // Fall back to a generated preimage only when no entryXdr field was sent at all. - const entryXdrText = - typeof entryXdr === "string" - ? entryXdr - : generateAuthPreimageXdr(network); - try { + // Use the supplied XDR (including empty string for testing). + // Fall back to a generated preimage only when no entryXdr field was sent at all. + const entryXdrText = + typeof entryXdr === "string" + ? entryXdr + : generateAuthPreimageXdr(network, preimageVariant); const requestPromise = wcClient.requestSignAuthEntry( metadata.topic, { entryXdr: entryXdrText }, diff --git a/package.json b/package.json index d2364e21d..2b6e7414d 100644 --- a/package.json +++ b/package.json @@ -85,7 +85,7 @@ "@shopify/react-native-skia": "2.2.21", "@stablelib/base64": "2.0.1", "@stablelib/utf8": "2.0.1", - "@stellar/stellar-sdk": "15.0.1", + "@stellar/stellar-sdk": "16.0.0-rc.1", "@stellar/typescript-wallet-sdk-km": "3.0.0", "@tradle/react-native-http": "2.0.0", "@walletconnect/core": "2.23.1", @@ -202,7 +202,7 @@ "typescript": "5.8.3" }, "engines": { - "node": ">=20" + "node": ">=22" }, "react-native": { "crypto": "react-native-crypto", diff --git a/src/components/screens/SignTransactionDetails/components/KeyVal.tsx b/src/components/screens/SignTransactionDetails/components/KeyVal.tsx index 7a384aa64..0c077fbfe 100644 --- a/src/components/screens/SignTransactionDetails/components/KeyVal.tsx +++ b/src/components/screens/SignTransactionDetails/components/KeyVal.tsx @@ -7,8 +7,7 @@ import { Claimant, LiquidityPoolAsset, Operation, - Signer, - SignerKeyOptions, + OperationRecord, StrKey, xdr, } from "@stellar/stellar-sdk"; @@ -27,6 +26,23 @@ import React, { useEffect, useState } from "react"; import { View } from "react-native"; import { getContractSpecs } from "services/backend"; +// v16 no longer exports the signer option types directly; derive them from the +// parsed-operation union so they track the SDK exactly. +type SetOptionsSigner = NonNullable< + Extract["signer"] +>; +type SignerKeyOptions = Extract< + OperationRecord, + { type: "revokeSignerSponsorship" } +>["signer"]; + +// Signer hash fields (sha256Hash / preAuthTx) parse to a Buffer at runtime but +// are typed `Buffer | string`; render either as an uppercase hex string. +const signerKeyToHex = (value: Buffer | string): string => + typeof value === "string" + ? value + : Buffer.from(value).toString("hex").toUpperCase(); + interface KeyValueListItemProps { operationKey: string; operationValue: string | number | React.ReactNode; @@ -244,12 +260,12 @@ export const PathList = ({ paths }: PathListProps) => { }; interface KeyValueSignerProps { - signer: Signer; + signer: SetOptionsSigner; } export const KeyValueSigner = ({ signer }: KeyValueSignerProps) => { const renderSignerType = () => { - if ("ed25519PublicKey" in signer) { + if (signer.ed25519PublicKey) { return ( { ); } - if ("sha256Hash" in signer) { + if (signer.sha256Hash) { return ( ); } - if ("preAuthTx" in signer) { + if (signer.preAuthTx) { return ( ); } - if ("ed25519SignedPayload" in signer) { + if (signer.ed25519SignedPayload) { return ( { - if ("ed25519PublicKey" in signer) { + if (signer.ed25519PublicKey) { return ( ); } - if ("preAuthTx" in signer) { + if (signer.preAuthTx) { return ( ); } - if ("ed25519SignedPayload" in signer) { + if (signer.ed25519SignedPayload) { return ( { +const RenderOperationByType = ({ + operation, +}: { + operation: OperationRecord; +}) => { const { t } = useAppTranslation(); const { network } = useAuthenticationStore(); const networkDetails = mapNetworkToNetworkDetails(network); @@ -497,9 +501,9 @@ const RenderOperationByType = ({ operation }: { operation: Operation }) => { copyToClipboard(line.issuer)} + onPress={() => copyToClipboard(line.issuer ?? "")} /> - {truncateAddress(line.issuer)} + {truncateAddress(line.issuer ?? "")} ), titleColor: themeColors.text.secondary, @@ -1054,7 +1058,11 @@ const RenderOperationByType = ({ operation }: { operation: Operation }) => { } }; -const RenderOperationArgsByType = ({ operation }: { operation: Operation }) => { +const RenderOperationArgsByType = ({ + operation, +}: { + operation: OperationRecord; +}) => { const { t } = useAppTranslation(); const { network } = useAuthenticationStore(); const networkDetails = mapNetworkToNetworkDetails(network); @@ -1202,7 +1210,9 @@ const Operations = ({ operations }: OperationsProps) => { > - {OPERATION_TYPES[type] || type} + + {OPERATION_TYPES[type as keyof typeof OPERATION_TYPES] || type} + {source && ( diff --git a/src/components/screens/SignTransactionDetails/components/SignTransactionAuthorizations.tsx b/src/components/screens/SignTransactionDetails/components/SignTransactionAuthorizations.tsx index 5324f249b..9f8e325bf 100644 --- a/src/components/screens/SignTransactionDetails/components/SignTransactionAuthorizations.tsx +++ b/src/components/screens/SignTransactionDetails/components/SignTransactionAuthorizations.tsx @@ -1,10 +1,10 @@ /* eslint-disable react/no-array-index-key */ -import { xdr } from "@stellar/stellar-sdk"; import CollapsibleSection from "components/CollapsibleSection"; import { KeyValueListItem, KeyValueInvokeHostFnArgs, } from "components/screens/SignTransactionDetails/components/KeyVal"; +import { AuthEntryDisplay } from "components/screens/SignTransactionDetails/types"; import Icon from "components/sds/Icon"; import { Text } from "components/sds/Typography"; import { @@ -21,7 +21,7 @@ import React from "react"; import { View } from "react-native"; interface SignTransactionAuthorizationsProps { - authEntries: xdr.SorobanAuthorizedInvocation[]; + authEntries: AuthEntryDisplay[]; } const SignTransactionAuthorizations = ({ @@ -170,11 +170,26 @@ const SignTransactionAuthorizations = ({ } }; - const renderAuthEntry = (authEntry: xdr.SorobanAuthorizedInvocation) => { - const invocationDetails = getInvocationDetails(authEntry); + const renderAuthEntry = ({ invocation, boundAddress }: AuthEntryDisplay) => { + const invocationDetails = getInvocationDetails(invocation); return ( + {boundAddress && ( + + {truncateAddress(boundAddress)} + copyToClipboard(boundAddress)} + /> + + } + /> + )} {invocationDetails.map((detail, detailIndex) => ( - {authEntries.map((authEntry, index) => ( - - {renderAuthEntry(authEntry)} + {authEntries.map((entry, index) => ( + + {renderAuthEntry(entry)} ))} diff --git a/src/components/screens/SignTransactionDetails/components/SignTransactionOperationDetails.tsx b/src/components/screens/SignTransactionDetails/components/SignTransactionOperationDetails.tsx index 0e784ecb6..4195a9d06 100644 --- a/src/components/screens/SignTransactionDetails/components/SignTransactionOperationDetails.tsx +++ b/src/components/screens/SignTransactionDetails/components/SignTransactionOperationDetails.tsx @@ -1,15 +1,15 @@ -import { Operation } from "@stellar/stellar-sdk"; +import { OperationRecord } from "@stellar/stellar-sdk"; import Operations from "components/screens/SignTransactionDetails/components/Operations"; import React, { useRef } from "react"; import { View } from "react-native"; interface SignTransactionOperationDetailsProps { - operations: Operation[]; + operations: OperationRecord[]; } const SignTransactionOperationDetails = React.memo(({ operations }) => { - const stableOperationsRef = useRef([]); + const stableOperationsRef = useRef([]); const hasInitializedRef = useRef(false); // Only set operations ONCE, never update them diff --git a/src/components/screens/SignTransactionDetails/hooks/useSignTransactionDetails.ts b/src/components/screens/SignTransactionDetails/hooks/useSignTransactionDetails.ts index 3ff67f871..6f614d369 100644 --- a/src/components/screens/SignTransactionDetails/hooks/useSignTransactionDetails.ts +++ b/src/components/screens/SignTransactionDetails/hooks/useSignTransactionDetails.ts @@ -13,6 +13,7 @@ import { mapNetworkToNetworkDetails, OPERATION_TYPES } from "config/constants"; import { logger } from "config/logger"; import { useAuthenticationStore } from "ducks/auth"; import { stroopToXlm } from "helpers/formatAmount"; +import { getAuthEntryBoundAddress } from "helpers/soroban"; interface UseSignTransactionDetailsParams { xdr: string; @@ -38,7 +39,7 @@ const buildSummary = ( const decodedMemo = decodeMemo(memo); const operations = transaction.operations.map( - (op) => OPERATION_TYPES[op.type] || op.type, + (op) => OPERATION_TYPES[op.type as keyof typeof OPERATION_TYPES] || op.type, ); const summary: SignTransactionSummaryInterface = { @@ -62,7 +63,10 @@ const buildAuthEntries = (transaction: Transaction | FeeBumpTransaction) => { if (!allAuthEntries.length) return []; - return allAuthEntries.map((authEntry) => authEntry.rootInvocation()); + return allAuthEntries.map((authEntry) => ({ + invocation: authEntry.rootInvocation(), + boundAddress: getAuthEntryBoundAddress(authEntry), + })); }; export const useSignTransactionDetails = ({ diff --git a/src/components/screens/SignTransactionDetails/types/index.ts b/src/components/screens/SignTransactionDetails/types/index.ts index 782c6dfa3..c3047e665 100644 --- a/src/components/screens/SignTransactionDetails/types/index.ts +++ b/src/components/screens/SignTransactionDetails/types/index.ts @@ -1,4 +1,4 @@ -import type { MemoType, Operation, xdr } from "@stellar/stellar-sdk"; +import type { MemoType, OperationRecord, xdr } from "@stellar/stellar-sdk"; export interface DecodedMemoInterface { value: string; @@ -18,9 +18,19 @@ export interface InvokeHostFunctionShortDetailsInterface { functionName?: string; } +export interface AuthEntryDisplay { + invocation: xdr.SorobanAuthorizedInvocation; + /** + * The address whose authorization the entry's credentials represent. + * Present for address credentials (incl. CAP-71 ADDRESS_V2 / + * ADDRESS_WITH_DELEGATES); absent for source-account credentials. + */ + boundAddress?: string; +} + export interface SignTransactionDetailsInterface { summary: SignTransactionSummaryInterface; - authEntries: xdr.SorobanAuthorizedInvocation[]; - operations: Operation[]; + authEntries: AuthEntryDisplay[]; + operations: OperationRecord[]; hasTrustlineChanges: boolean; } diff --git a/src/components/screens/WalletKit/DappAuthEntryDisplay.tsx b/src/components/screens/WalletKit/DappAuthEntryDisplay.tsx index dfd3786cc..04f674508 100644 --- a/src/components/screens/WalletKit/DappAuthEntryDisplay.tsx +++ b/src/components/screens/WalletKit/DappAuthEntryDisplay.tsx @@ -4,12 +4,17 @@ import Icon from "components/sds/Icon"; import { Text } from "components/sds/Typography"; import { logger } from "config/logger"; import { + addressToString, getInvocationDetails, InvocationArgs, INVOCATION_TYPE_INVOKE, INVOCATION_TYPE_WASM, } from "helpers/soroban"; import { truncateAddress } from "helpers/stellar"; +import { + normalizeAuthPreimage, + NormalizedAuthPreimage, +} from "helpers/walletKitValidation"; import useAppTranslation from "hooks/useAppTranslation"; import { useClipboard } from "hooks/useClipboard"; import useColors from "hooks/useColors"; @@ -17,7 +22,10 @@ import React, { useMemo, useState } from "react"; import { ScrollView, TouchableOpacity, View } from "react-native"; interface DappAuthEntryDisplayProps { - /** Base64-encoded HashIdPreimage XDR (HashIdPreimage.envelopeTypeSorobanAuthorization) */ + /** + * Base64-encoded HashIdPreimage XDR — envelopeTypeSorobanAuthorization or + * the CAP-71 / Protocol 27 envelopeTypeSorobanAuthorizationWithAddress arm + */ entryXdr: string; /** When true all invocation cards start expanded */ expandAll?: boolean; @@ -37,31 +45,41 @@ export const DappAuthEntryDisplay: React.FC = ({ const { t } = useAppTranslation(); const { copyToClipboard } = useClipboard(); - // Lazy initialiser: when expandAll, parse once to know how many indices exist - const [expandedIndices, setExpandedIndices] = useState>(() => { - if (!expandAll) return new Set(); + // Parse once and normalize across both authorization preimage arms + const normalized = useMemo(() => { try { - const preimage = xdr.HashIdPreimage.fromXDR(entryXdr, "base64"); - const d = getInvocationDetails( - preimage.sorobanAuthorization().invocation(), + return normalizeAuthPreimage( + xdr.HashIdPreimage.fromXDR(entryXdr, "base64"), ); - return new Set(d.map((_, i) => i)); - } catch { - return new Set(); + } catch (e) { + logger.warn("DappAuthEntryDisplay", "Failed to parse auth entry XDR", { + error: e, + }); + return null; } - }); + }, [entryXdr]); const details = useMemo(() => { try { - const preimage = xdr.HashIdPreimage.fromXDR(entryXdr, "base64"); - return getInvocationDetails(preimage.sorobanAuthorization().invocation()); + return normalized ? getInvocationDetails(normalized.invocation) : []; } catch (e) { logger.warn("DappAuthEntryDisplay", "Failed to parse auth entry XDR", { error: e, }); return []; } - }, [entryXdr]); + }, [normalized]); + + // CAP-71 withAddress preimages bind the signer address — surface it + const signerAddress = useMemo( + () => (normalized?.address ? addressToString(normalized.address) : null), + [normalized], + ); + + // Lazy initialiser: when expandAll, start with every invocation card open + const [expandedIndices, setExpandedIndices] = useState>(() => + expandAll ? new Set(details.map((_, i) => i)) : new Set(), + ); const toggleExpanded = (index: number) => { setExpandedIndices((prev) => { @@ -198,6 +216,28 @@ export const DappAuthEntryDisplay: React.FC = ({ + {signerAddress && ( + + + {t("signTransactionDetails.authorizations.address")} + + + + {truncateAddress(signerAddress)} + + copyToClipboard(signerAddress)} + /> + + + )} + {details.length > 0 ? ( details.map((detail, index) => { const isExpanded = expandedIndices.has(index); diff --git a/src/helpers/soroban.ts b/src/helpers/soroban.ts index cac117375..d35d59de8 100644 --- a/src/helpers/soroban.ts +++ b/src/helpers/soroban.ts @@ -3,11 +3,10 @@ /* eslint-disable @typescript-eslint/no-unsafe-member-access */ /* eslint-disable @typescript-eslint/no-unsafe-return */ import { - MemoType, - Memo, StrKey, TransactionBuilder, Operation, + OperationRecord, Transaction, Horizon, xdr, @@ -133,6 +132,41 @@ export const addressToString = (address: xdr.ScAddress) => { return Address.fromScAddress(address).toString(); }; +/** + * Extracts the address credentials from a SorobanCredentials union, handling + * all CAP-71 address arms. Returns null for source-account credentials, which + * carry no address payload. + */ +export const getAddressCredentials = ( + credentials: xdr.SorobanCredentials, +): xdr.SorobanAddressCredentials | null => { + switch (credentials.switch().value) { + case xdr.SorobanCredentialsType.sorobanCredentialsAddress().value: + return credentials.address(); + case xdr.SorobanCredentialsType.sorobanCredentialsAddressV2().value: + return credentials.addressV2(); + case xdr.SorobanCredentialsType.sorobanCredentialsAddressWithDelegates() + .value: + return credentials.addressWithDelegates().addressCredentials(); + default: + return null; + } +}; + +/** + * Returns the address a Soroban authorization entry is bound to (the address + * whose authorization its credentials represent), or undefined for + * source-account credentials. + */ +export const getAuthEntryBoundAddress = ( + entry: xdr.SorobanAuthorizationEntry, +): string | undefined => { + const addressCredentials = getAddressCredentials(entry.credentials()); + return addressCredentials + ? addressToString(addressCredentials.address()) + : undefined; +}; + export const getArgsForTokenInvocation = ( fnName: string, args: xdr.ScVal[], @@ -216,7 +250,7 @@ export const getTokenInvocationArgs = ( }; export const isSorobanOp = ( - operation: Horizon.ServerApi.OperationRecord | Operation, + operation: Horizon.ServerApi.OperationRecord | OperationRecord, ) => SOROBAN_OPERATION_TYPES.includes(operation.type); export const hasSorobanOperations = ( @@ -244,9 +278,11 @@ export const getAttrsFromSorobanHorizonOp = ( const transaction = TransactionBuilder.fromXDR( op.transaction_attr.envelope_xdr as string, networkDetails.networkPassphrase, - ) as Transaction, Operation.InvokeHostFunction[]>; + ) as Transaction; - const invokeHostFn = transaction.operations[0]; // only one op per tx in Soroban right now + // only one op per tx in Soroban right now + const invokeHostFn = transaction + .operations[0] as Operation.InvokeHostFunction; return getTokenInvocationArgs(invokeHostFn); }; diff --git a/src/helpers/stellar.ts b/src/helpers/stellar.ts index f96a1e48e..5cf51da63 100644 --- a/src/helpers/stellar.ts +++ b/src/helpers/stellar.ts @@ -8,6 +8,10 @@ import { } from "@stellar/stellar-sdk"; import { logger } from "config/logger"; import { isContractId } from "helpers/soroban"; +import { + normalizeAuthPreimage, + validateAuthEntryAddress, +} from "helpers/walletKitValidation"; /** * Checks if an address is a federation address (username*domain.com format) @@ -387,7 +391,8 @@ export const signMessage = (message: string, privateKey: string): string => { * Ed25519 signature alongside the signer address. * * @param preimageXdr - Base64-encoded HashIdPreimage XDR - * (HashIdPreimage.envelopeTypeSorobanAuthorization) + * (envelopeTypeSorobanAuthorization, or the CAP-71 / Protocol 27 + * envelopeTypeSorobanAuthorizationWithAddress sent for ADDRESS_V2 credentials) * @param privateKey - Account's secret key (S...) * @returns signedAuthEntry (base64 Ed25519 signature) and signerAddress (G... public key) * @@ -403,11 +408,22 @@ export const signAuthEntry = ( preimageXdr: string, privateKey: string, ): { signedAuthEntry: string; signerAddress: string } => { - // Validate that the XDR is a HashIdPreimage.envelopeTypeSorobanAuthorization - // before signing — rejects arbitrary blobs that do not conform to SEP-43. - xdr.HashIdPreimage.fromXDR(preimageXdr, "base64").sorobanAuthorization(); + // Validate that the XDR is a Soroban authorization HashIdPreimage (either + // arm) before signing — rejects arbitrary blobs that do not conform to SEP-43. + const preimage = xdr.HashIdPreimage.fromXDR(preimageXdr, "base64"); + if (!normalizeAuthPreimage(preimage)) { + throw new Error("Unsupported auth entry preimage type"); + } const keyPair = Keypair.fromSecret(privateKey); + + // Defense-in-depth: a CAP-71 (ADDRESS_V2) preimage binds a signer address — + // never sign one bound to a different account. Pre-validation should already + // have rejected this, so it's a backstop against bypass. + if (!validateAuthEntryAddress(preimage, keyPair.publicKey()).valid) { + throw new Error("Auth entry is bound to a different account"); + } + // SEP-43: hash the raw preimage bytes and sign — identical to the extension const signingPayload = hash(Buffer.from(preimageXdr, "base64")); const signature = keyPair.sign(signingPayload); diff --git a/src/helpers/walletKitUtil.ts b/src/helpers/walletKitUtil.ts index cfa8d32cc..91833d365 100644 --- a/src/helpers/walletKitUtil.ts +++ b/src/helpers/walletKitUtil.ts @@ -235,6 +235,7 @@ export const approveSessionRequest = async ({ signMessage, signAuthEntry, networkPassphrase, + publicKey, activeChain, showToast, t, @@ -248,6 +249,7 @@ export const approveSessionRequest = async ({ preimageXdr: string, ) => { signedAuthEntry: string; signerAddress: string } | null; networkPassphrase: string; + publicKey: string; activeChain: string; showToast: (options: ToastOptions) => void; t: TFunction<"translations", undefined>; @@ -392,7 +394,11 @@ export const approveSessionRequest = async ({ // Defense-in-depth: validate content, XDR format, and network ID. // These should already be caught by WalletKitProvider pre-validation, // but we re-check at approval time to prevent any bypass. - const validationResult = validateSignAuthEntry(entryXdr, networkPassphrase); + const validationResult = validateSignAuthEntry( + entryXdr, + networkPassphrase, + publicKey, + ); if (!validationResult.valid) { const errorMessage = t(validationResult.errorKey); showToast({ diff --git a/src/helpers/walletKitValidation.ts b/src/helpers/walletKitValidation.ts index c1be2fd6a..b5458b7c9 100644 --- a/src/helpers/walletKitValidation.ts +++ b/src/helpers/walletKitValidation.ts @@ -2,7 +2,7 @@ * Shared validation helpers for WalletKit sign requests. * Used by both WalletKitProvider (pre-UI validation) and walletKitUtil (approval-time validation). */ -import { hash, xdr } from "@stellar/stellar-sdk"; +import { hash, StrKey, xdr } from "@stellar/stellar-sdk"; // ───────────────────────────────────────────────────────────────────────────── // Error Keys (full i18n translation key paths) @@ -13,6 +13,7 @@ export const ValidationErrorKeys = { MESSAGE_TOO_LONG: "walletKit.errorMessageTooLong", INVALID_AUTH_ENTRY: "walletKit.errorInvalidAuthEntry", AUTH_ENTRY_NETWORK_MISMATCH: "walletKit.errorAuthEntryNetworkMismatch", + AUTH_ENTRY_ADDRESS_MISMATCH: "walletKit.errorAuthEntryAddressMismatch", } as const; /** Max UTF-8 byte length for sign_message content per SEP-53. */ @@ -91,32 +92,116 @@ export function parseAuthEntryPreimage( } } +/** + * The arm-agnostic shape of a Soroban authorization preimage. + * CAP-71 (Protocol 27) adds `envelopeTypeSorobanAuthorizationWithAddress`, + * which dapps on protocol 27 send for ADDRESS_V2 credentials — it carries the + * signer address in addition to the legacy fields. + */ +export interface NormalizedAuthPreimage { + networkId: Buffer; + invocation: xdr.SorobanAuthorizedInvocation; + /** Present only for envelopeTypeSorobanAuthorizationWithAddress (CAP-71 ADDRESS_V2). */ + address?: xdr.ScAddress; +} + +/** + * Normalizes a HashIdPreimage into a single shape regardless of which Soroban + * authorization arm it uses. Returns null for non-authorization preimage types. + */ +export function normalizeAuthPreimage( + preimage: xdr.HashIdPreimage, +): NormalizedAuthPreimage | null { + try { + switch (preimage.switch()) { + case xdr.EnvelopeType.envelopeTypeSorobanAuthorization(): { + const auth = preimage.sorobanAuthorization(); + return { networkId: auth.networkId(), invocation: auth.invocation() }; + } + case xdr.EnvelopeType.envelopeTypeSorobanAuthorizationWithAddress(): { + const auth = preimage.sorobanAuthorizationWithAddress(); + return { + networkId: auth.networkId(), + invocation: auth.invocation(), + address: auth.address(), + }; + } + default: + // Not a Soroban authorization preimage — unsupported for signing. + return null; + } + } catch (e) { + // Malformed preimage object — treat as unsupported. + return null; + } +} + /** * Validates that the networkId in the preimage matches the expected network. - * Returns the invocation from the sorobanAuthorization on success. + * Returns the invocation from the authorization preimage on success. + * Supports both envelopeTypeSorobanAuthorization and the CAP-71 + * envelopeTypeSorobanAuthorizationWithAddress arms. */ export function validateAuthEntryNetwork( preimage: xdr.HashIdPreimage, networkPassphrase: string, ): ValidationResult { - try { - const sorobanAuth = preimage.sorobanAuthorization(); - const embeddedNetworkId = sorobanAuth.networkId(); - const expectedNetworkId = hash(Buffer.from(networkPassphrase)); - - if (!embeddedNetworkId.equals(expectedNetworkId)) { - return { - valid: false, - errorKey: ValidationErrorKeys.AUTH_ENTRY_NETWORK_MISMATCH, - }; - } + const normalized = normalizeAuthPreimage(preimage); + if (!normalized) { + // The preimage type is not a Soroban authorization — treat as invalid. + return { valid: false, errorKey: ValidationErrorKeys.INVALID_AUTH_ENTRY }; + } - return { valid: true, value: sorobanAuth.invocation() }; - } catch (e) { - // If we can't extract sorobanAuthorization, the preimage type is not - // envelopeTypeSorobanAuthorization — treat as invalid. + const expectedNetworkId = hash(Buffer.from(networkPassphrase)); + if (!normalized.networkId.equals(expectedNetworkId)) { + return { + valid: false, + errorKey: ValidationErrorKeys.AUTH_ENTRY_NETWORK_MISMATCH, + }; + } + + return { valid: true, value: normalized.invocation }; +} + +/** + * Validates that a CAP-71 (ADDRESS_V2) preimage is bound to the active wallet + * account. The withAddress preimage embeds the signer address; if it is an + * account address that differs from the wallet, signing would produce a + * signature bound to someone else — reject it. + * + * Legacy preimages (no bound address) and contract-bound addresses (which a + * wallet account key can't match — e.g. delegate/contract signers) pass through. + */ +export function validateAuthEntryAddress( + preimage: xdr.HashIdPreimage, + walletPublicKey: string, +): ValidationResult { + const normalized = normalizeAuthPreimage(preimage); + if (!normalized) { return { valid: false, errorKey: ValidationErrorKeys.INVALID_AUTH_ENTRY }; } + + // Legacy arm carries no bound address — nothing to enforce. + if (!normalized.address) { + return { valid: true, value: true }; + } + + // Only enforce for account-type bound addresses. + if (normalized.address.switch().name !== "scAddressTypeAccount") { + return { valid: true, value: true }; + } + + const boundAddress = StrKey.encodeEd25519PublicKey( + normalized.address.accountId().ed25519(), + ); + if (boundAddress !== walletPublicKey) { + return { + valid: false, + errorKey: ValidationErrorKeys.AUTH_ENTRY_ADDRESS_MISMATCH, + }; + } + + return { valid: true, value: true }; } // ───────────────────────────────────────────────────────────────────────────── @@ -130,6 +215,7 @@ export function validateAuthEntryNetwork( export function validateSignAuthEntry( entryXdr: unknown, networkPassphrase: string, + walletPublicKey: string, ): ValidationResult { // Step 1: Content validation const contentResult = validateSignAuthEntryContent(entryXdr); @@ -146,5 +232,12 @@ export function validateSignAuthEntry( ); if (!networkResult.valid) return networkResult; + // Step 4: Address binding (CAP-71 ADDRESS_V2) — must match the active wallet + const addressResult = validateAuthEntryAddress( + parseResult.value, + walletPublicKey, + ); + if (!addressResult.valid) return addressResult; + return { valid: true, value: parseResult.value }; } diff --git a/src/i18n/locales/en/translations.json b/src/i18n/locales/en/translations.json index e736f2cac..0172588d4 100644 --- a/src/i18n/locales/en/translations.json +++ b/src/i18n/locales/en/translations.json @@ -944,6 +944,7 @@ "errorInvalidMessage": "Message is missing or empty", "errorInvalidAuthEntry": "Authorization entry XDR is missing or malformed", "errorAuthEntryNetworkMismatch": "Authorization entry is for a different network", + "errorAuthEntryAddressMismatch": "Authorization entry is bound to a different account", "errorEmptyMessage": "Cannot sign empty message", "errorMessageTooLong": "Message too long (max 1KB)", "errorSubmitting": "Failed to submit transaction", @@ -1231,7 +1232,8 @@ "contractId": "Contract ID", "functionName": "Function name", "parameters": "Parameters", - "contractAddress": "Contract address" + "contractAddress": "Contract address", + "address": "Signer address" }, "operations": { "destinationWithNumber": "Destination #{{number}}", diff --git a/src/i18n/locales/pt/translations.json b/src/i18n/locales/pt/translations.json index a6fa7e315..25230a296 100644 --- a/src/i18n/locales/pt/translations.json +++ b/src/i18n/locales/pt/translations.json @@ -908,6 +908,7 @@ "errorInvalidMessage": "A mensagem está ausente ou vazia", "errorInvalidAuthEntry": "O XDR da entrada de autorização está ausente ou malformado", "errorAuthEntryNetworkMismatch": "A entrada de autorização é para uma rede diferente", + "errorAuthEntryAddressMismatch": "A entrada de autorização está vinculada a uma conta diferente", "errorEmptyMessage": "Não é possível assinar mensagem vazia", "errorMessageTooLong": "Mensagem muito longa (máximo 1KB)", "errorSubmitting": "Erro ao enviar a transação", @@ -1232,7 +1233,8 @@ "contractId": "ID do Contrato", "functionName": "Nome da função", "parameters": "Parâmetros", - "contractAddress": "Endereço do contrato" + "contractAddress": "Endereço do contrato", + "address": "Endereço do signatário" }, "operations": { "destinationWithNumber": "Destino #{{number}}", diff --git a/src/providers/WalletKitProvider.tsx b/src/providers/WalletKitProvider.tsx index af040a226..5c8433e14 100644 --- a/src/providers/WalletKitProvider.tsx +++ b/src/providers/WalletKitProvider.tsx @@ -39,6 +39,7 @@ import { validateSignAuthEntryContent, parseAuthEntryPreimage, validateAuthEntryNetwork, + validateAuthEntryAddress, } from "helpers/walletKitValidation"; import { useBlockaidSite } from "hooks/blockaid/useBlockaidSite"; import { useBlockaidTransaction } from "hooks/blockaid/useBlockaidTransaction"; @@ -539,6 +540,7 @@ export const WalletKitProvider: React.FC = ({ signMessage, signAuthEntry, networkPassphrase: networkDetails.networkPassphrase, + publicKey, activeChain, showToast, t, @@ -794,6 +796,33 @@ export const WalletKitProvider: React.FC = ({ return true; }; + /** + * Validates that a CAP-71 (ADDRESS_V2) preimage is bound to the active + * wallet account. Rejects the request on mismatch. + */ + const prevalidateSignAuthEntryAddress = ( + sessionRequest: WalletKitSessionRequest, + preimage: stellarXdr.HashIdPreimage, + ): boolean => { + const result = validateAuthEntryAddress(preimage, publicKey); + if (!result.valid) { + showToast({ + title: t("walletKit.invalidRequestTitle"), + message: t(result.errorKey), + variant: "error", + }); + rejectSessionRequest({ + sessionRequest, + message: t(result.errorKey), + }); + clearEvent(); + isProcessingRequestRef.current = false; + return false; + } + + return true; + }; + /** * Orchestrates all sign_auth_entry pre-validations. * @returns true if all validations pass, false if any fail (rejection handled) @@ -821,6 +850,11 @@ export const WalletKitProvider: React.FC = ({ return false; } + // Step 4: Validate bound address (CAP-71 ADDRESS_V2) matches active wallet + if (!prevalidateSignAuthEntryAddress(sessionRequest, preimage)) { + return false; + } + return true; }; diff --git a/yarn.lock b/yarn.lock index cb781f77d..b356de0b2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3082,6 +3082,13 @@ __metadata: languageName: node linkType: hard +"@noble/ed25519@npm:^3.1.0": + version: 3.1.0 + resolution: "@noble/ed25519@npm:3.1.0" + checksum: 10c0/6317722130649cf5884eff57d540b36ec1006cf68145b09ff427d88df359a3b12baaaf420aad87b747f49d30c5ae56065bf31e5ebde1a6d59c3ce9d0b6a68fdb + languageName: node + linkType: hard + "@noble/hashes@npm:1.7.0": version: 1.7.0 resolution: "@noble/hashes@npm:1.7.0" @@ -3096,6 +3103,13 @@ __metadata: languageName: node linkType: hard +"@noble/hashes@npm:^2.2.0": + version: 2.2.0 + resolution: "@noble/hashes@npm:2.2.0" + checksum: 10c0/cad8630c504d6b9271984f685cd0af9101b40988fc2dfbe17ccdf068a9941f95cc5c9957d89e9ca4b7ca94c15cb35f93510c5d53a09fbcc83ee503b93d0a1034 + languageName: node + linkType: hard + "@nodelib/fs.scandir@npm:2.1.5": version: 2.1.5 resolution: "@nodelib/fs.scandir@npm:2.1.5" @@ -4653,6 +4667,13 @@ __metadata: languageName: node linkType: hard +"@stellar/js-xdr@npm:4.0.0, @stellar/js-xdr@npm:^4.0.0": + version: 4.0.0 + resolution: "@stellar/js-xdr@npm:4.0.0" + checksum: 10c0/617bb3d7fa9fbdb607e5cb42cc032a15ad1c111440c6d7ede40fcecde204e88ed351aebc80ce1e4eedac6c1d949c3da5722696611e2b00569e7edbd5a0634c37 + languageName: node + linkType: hard + "@stellar/js-xdr@npm:^3.1.2": version: 3.1.2 resolution: "@stellar/js-xdr@npm:3.1.2" @@ -4660,13 +4681,6 @@ __metadata: languageName: node linkType: hard -"@stellar/js-xdr@npm:^4.0.0": - version: 4.0.0 - resolution: "@stellar/js-xdr@npm:4.0.0" - checksum: 10c0/617bb3d7fa9fbdb607e5cb42cc032a15ad1c111440c6d7ede40fcecde204e88ed351aebc80ce1e4eedac6c1d949c3da5722696611e2b00569e7edbd5a0634c37 - languageName: node - linkType: hard - "@stellar/stellar-base@npm:13.1.0": version: 13.1.0 resolution: "@stellar/stellar-base@npm:13.1.0" @@ -4718,6 +4732,28 @@ __metadata: languageName: node linkType: hard +"@stellar/stellar-sdk@npm:16.0.0-rc.1": + version: 16.0.0-rc.1 + resolution: "@stellar/stellar-sdk@npm:16.0.0-rc.1" + dependencies: + "@noble/ed25519": "npm:^3.1.0" + "@noble/hashes": "npm:^2.2.0" + "@stellar/js-xdr": "npm:4.0.0" + axios: "npm:1.16.1" + base32.js: "npm:^0.1.0" + bignumber.js: "npm:^11.1.1" + buffer: "npm:^6.0.3" + commander: "npm:^14.0.3" + eventsource: "npm:^4.1.0" + feaxios: "npm:^0.0.23" + smol-toml: "npm:^1.6.1" + uint8array-extras: "npm:^1.5.0" + bin: + stellar-js: bin/stellar-js + checksum: 10c0/0541092c5c5819d781b6359315a47785757f1c6a0318435b2183c8445b339ecaad147305ce8e204bb0f3ba5ce5bf0e27a491911ac59b7d9262858dfd413687a0 + languageName: node + linkType: hard + "@stellar/typescript-wallet-sdk-km@npm:3.0.0": version: 3.0.0 resolution: "@stellar/typescript-wallet-sdk-km@npm:3.0.0" @@ -6632,6 +6668,18 @@ __metadata: languageName: node linkType: hard +"axios@npm:1.16.1": + version: 1.16.1 + resolution: "axios@npm:1.16.1" + dependencies: + follow-redirects: "npm:^1.16.0" + form-data: "npm:^4.0.5" + https-proxy-agent: "npm:^5.0.1" + proxy-from-env: "npm:^2.1.0" + checksum: 10c0/2f77e37e6552bbff8a772d058fb09500198e9188c6b20dc799d82dbe12a8cb506f6eed4e4e62a9ba612a35cbab496faa26d68f9bff14a53af6d15c3e136391a7 + languageName: node + linkType: hard + "axobject-query@npm:^4.1.0": version: 4.1.0 resolution: "axobject-query@npm:4.1.0" @@ -7035,6 +7083,13 @@ __metadata: languageName: node linkType: hard +"bignumber.js@npm:^11.1.1": + version: 11.1.3 + resolution: "bignumber.js@npm:11.1.3" + checksum: 10c0/5ab91a37ee92d334d7df92b79a0340b559e75d78ed0175f497243b8b043e00368e26b4f5fbed15ab129d922337c22cfe20d8b861cd9ef25e0d4e1dbae6b25c3b + languageName: node + linkType: hard + "bignumber.js@npm:^9.3.1": version: 9.3.1 resolution: "bignumber.js@npm:9.3.1" @@ -9408,6 +9463,13 @@ __metadata: languageName: node linkType: hard +"eventsource-parser@npm:^3.0.1": + version: 3.1.0 + resolution: "eventsource-parser@npm:3.1.0" + checksum: 10c0/5ab4c6c9a2a042be0b387b6d03810eb580bac4ce90e299ede56458125a97ffe3af8145b2740089fc898a96cfa5aae792ee79f2a06257fba2776b0e7bce037071 + languageName: node + linkType: hard + "eventsource@npm:^2.0.2": version: 2.0.2 resolution: "eventsource@npm:2.0.2" @@ -9415,6 +9477,15 @@ __metadata: languageName: node linkType: hard +"eventsource@npm:^4.1.0": + version: 4.1.0 + resolution: "eventsource@npm:4.1.0" + dependencies: + eventsource-parser: "npm:^3.0.1" + checksum: 10c0/5d8f0f60fdafc5a3b4ce5d53f6a6c75cf07fbec250cfb870c44ea38d9689b9996e4a6f3779e520a6e7d32fbf398f42ea011a2c10772a9bc932fb419accede73e + languageName: node + linkType: hard + "evp_bytestokey@npm:^1.0.0, evp_bytestokey@npm:^1.0.3": version: 1.0.3 resolution: "evp_bytestokey@npm:1.0.3" @@ -9732,7 +9803,7 @@ __metadata: languageName: node linkType: hard -"follow-redirects@npm:^1.15.11": +"follow-redirects@npm:^1.15.11, follow-redirects@npm:^1.16.0": version: 1.16.0 resolution: "follow-redirects@npm:1.16.0" peerDependenciesMeta: @@ -9861,7 +9932,7 @@ __metadata: "@shopify/react-native-skia": "npm:2.2.21" "@stablelib/base64": "npm:2.0.1" "@stablelib/utf8": "npm:2.0.1" - "@stellar/stellar-sdk": "npm:15.0.1" + "@stellar/stellar-sdk": "npm:16.0.0-rc.1" "@stellar/typescript-wallet-sdk-km": "npm:3.0.0" "@testing-library/jest-native": "npm:5.4.3" "@testing-library/react-hooks": "npm:8.0.1" @@ -10600,7 +10671,7 @@ __metadata: languageName: node linkType: hard -"https-proxy-agent@npm:^5.0.0": +"https-proxy-agent@npm:^5.0.0, https-proxy-agent@npm:^5.0.1": version: 5.0.1 resolution: "https-proxy-agent@npm:5.0.1" dependencies: @@ -16468,6 +16539,13 @@ __metadata: languageName: node linkType: hard +"smol-toml@npm:^1.6.1": + version: 1.6.1 + resolution: "smol-toml@npm:1.6.1" + checksum: 10c0/511a78722f99c7616fdb46af708de3d7e81434b5a3d58061166da73f28bfc6cae4f0cd04683f60515b9c490cd10152fce72287c960b337419c0299cc1f0f2a22 + languageName: node + linkType: hard + "snake-case@npm:^3.0.4": version: 3.0.4 resolution: "snake-case@npm:3.0.4" @@ -17645,6 +17723,13 @@ __metadata: languageName: node linkType: hard +"uint8array-extras@npm:^1.5.0": + version: 1.5.0 + resolution: "uint8array-extras@npm:1.5.0" + checksum: 10c0/0e74641ac7dadb02eadefc1ccdadba6010e007757bda824960de3c72bbe2b04e6d3af75648441f412148c4103261d54fcb60be45a2863beb76643a55fddba3bd + languageName: node + linkType: hard + "uint8arrays@npm:3.1.1, uint8arrays@npm:^3.0.0": version: 3.1.1 resolution: "uint8arrays@npm:3.1.1" From 4df5c71baeaa5a695b6a95435e9e1088ed69fe1a Mon Sep 17 00:00:00 2001 From: Aristides Staffieri Date: Fri, 12 Jun 2026 11:47:46 -0600 Subject: [PATCH 2/4] Here's a commit message for the review-feedback fixes (the 8 modified files: 7 workflows + metro.config.js): MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit chore: address PR review — bump CI to Node 22, harden bignumber metro fix The @stellar/stellar-sdk 16 upgrade raised the Node engine requirement to >=22, but CI workflows still pinned Node 20. Align them so CI runs on the version the SDK requires. - Bump NODE_VERSION 20 -> 22 in test, android, ios, android-e2e, ios-e2e, and prPreviewIos workflows; update the inline node-version in new-release - metro.config.js: normalize path separators before matching the bignumber.js v11 rewrite so it also triggers on Windows (backslash) paths, and rewrite the filename precisely (bignumber.js -> bignumber.cjs) --- .github/workflows/android-e2e.yml | 2 +- .github/workflows/android.yml | 2 +- .github/workflows/ios-e2e.yml | 2 +- .github/workflows/ios.yml | 2 +- .github/workflows/new-release.yml | 2 +- .github/workflows/prPreviewIos.yml | 2 +- .github/workflows/test.yml | 2 +- metro.config.js | 9 +++++++-- 8 files changed, 14 insertions(+), 9 deletions(-) diff --git a/.github/workflows/android-e2e.yml b/.github/workflows/android-e2e.yml index 6ead76ed6..13d7db9d7 100644 --- a/.github/workflows/android-e2e.yml +++ b/.github/workflows/android-e2e.yml @@ -17,7 +17,7 @@ on: type: string env: - NODE_VERSION: "20" + NODE_VERSION: "22" RUBY_VERSION: 3.1.4 JAVA_VERSION: "17" SKIP_COCOAPODS: "yes" diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index abb92b8b6..2a285e286 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -41,7 +41,7 @@ concurrency: cancel-in-progress: true env: - NODE_VERSION: "20" + NODE_VERSION: "22" RUBY_VERSION: 3.1.4 JAVA_VERSION: "17" SKIP_COCOAPODS: "yes" diff --git a/.github/workflows/ios-e2e.yml b/.github/workflows/ios-e2e.yml index 8b3905b04..1322f2286 100644 --- a/.github/workflows/ios-e2e.yml +++ b/.github/workflows/ios-e2e.yml @@ -17,7 +17,7 @@ on: type: string env: - NODE_VERSION: "20" + NODE_VERSION: "22" RUBY_VERSION: 3.1.4 APP_ID: "org.stellar.freighterdev" APPLE_CONNECT_KEY_ID: "skip-android-16kb-setup" diff --git a/.github/workflows/ios.yml b/.github/workflows/ios.yml index fbc97d060..a34b27ea1 100644 --- a/.github/workflows/ios.yml +++ b/.github/workflows/ios.yml @@ -40,7 +40,7 @@ concurrency: cancel-in-progress: true env: - NODE_VERSION: "20" + NODE_VERSION: "22" RUBY_VERSION: 3.1.4 # Apple Connect API configuration (for app_store_connect_api_key) diff --git a/.github/workflows/new-release.yml b/.github/workflows/new-release.yml index 6bdd5b140..ca2c95a91 100644 --- a/.github/workflows/new-release.yml +++ b/.github/workflows/new-release.yml @@ -57,7 +57,7 @@ jobs: - name: Set up Node uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6 with: - node-version: 20 + node-version: 22 - name: Configure Git user run: | diff --git a/.github/workflows/prPreviewIos.yml b/.github/workflows/prPreviewIos.yml index 14c9b3b46..6a0019990 100644 --- a/.github/workflows/prPreviewIos.yml +++ b/.github/workflows/prPreviewIos.yml @@ -68,7 +68,7 @@ jobs: contents: write # draft release create/delete + tag operations pull-requests: write # sticky preview-link comment env: - NODE_VERSION: "20" + NODE_VERSION: "22" RUBY_VERSION: 3.1.4 # === Telemetry intentionally disabled in previews === SENTRY_PROPERTIES_CONTENT: "disabled-for-preview" diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 2d931a2ec..823b2caea 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -7,7 +7,7 @@ on: branches: [main, release, emergency-release, "v*.*.*"] env: - NODE_VERSION: "20" + NODE_VERSION: "22" RUBY_VERSION: 3.1.4 JAVA_VERSION: "17" diff --git a/metro.config.js b/metro.config.js index f558acc31..09b4ec30b 100644 --- a/metro.config.js +++ b/metro.config.js @@ -73,13 +73,18 @@ const config = { // `require("bignumber.js")` resolves to {} and the SDK's // `_interopDefault(...).default.clone()` throws. Rewrite that one broken // target back to the real CJS build. (v9, used by stellar-base, is fine.) + // Normalize separators so the match also holds on Windows (backslashes). + const resolvedPath = resolved?.filePath?.replace(/\\/g, "/"); if ( moduleName === "bignumber.js" && - resolved?.filePath?.endsWith("/dist/bignumber.js") + resolvedPath?.endsWith("/dist/bignumber.js") ) { return { ...resolved, - filePath: resolved.filePath.replace(/\.js$/, ".cjs"), + filePath: resolved.filePath.replace( + /bignumber\.js$/, + "bignumber.cjs", + ), }; } From f970b5ec8222621774074557c71d352b217a5a98 Mon Sep 17 00:00:00 2001 From: Aristides Staffieri Date: Tue, 16 Jun 2026 11:58:23 -0600 Subject: [PATCH 3/4] fix: restore transaction submission under stellar-sdk 16 on React Native MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The stellar-sdk 15 → 16 bump (this branch) swapped the SDK's bundled XHR-based axios client for feaxios, a fetch-based shim. Two of its fetch-isms aren't satisfied by React Native's Hermes runtime, breaking every transaction submit (stellar_signAndSubmitXDR over WalletConnect and the in-app Swap), while GET requests kept working: 1. AbortSignal.timeout / AbortSignal.any are missing in Hermes. feaxios calls AbortSignal.timeout() on any request with a timeout option, and submitTransaction is the only call that sets one — so submits threw "undefined is not a function" (Sentry FREIGHTER-MOBILE-YN) while GETs, which pass no timeout, were fine. 2. URLSearchParams request bodies aren't serialized by RN. feaxios turns the SDK's form-urlencoded "tx=..." string body into a URLSearchParams object; whatwg-fetch (RN's fetch) then calls xhr.send() with the object, and RN's convertRequestBody only handles string/Blob/FormData/ArrayBuffer. The body went out empty, so Horizon rejected submits as transaction_malformed with an empty envelope_xdr. Fixes: - Add src/polyfills/abortSignal.ts polyfilling AbortSignal.timeout and AbortSignal.any (guarded; no-ops where the runtime already has them), wired into bootstrap.js. - Patch XMLHttpRequest.send in src/polyfills/xhr.ts to stringify URLSearchParams bodies — the boundary where RN drops them. - Add regression tests for both polyfills. --- __tests__/polyfills/abortSignal.test.ts | 106 ++++++++++++++++++++++++ __tests__/polyfills/xhr.test.ts | 70 ++++++++++++++++ src/bootstrap.js | 4 + src/polyfills/abortSignal.ts | 75 +++++++++++++++++ src/polyfills/xhr.ts | 42 ++++++++-- 5 files changed, 292 insertions(+), 5 deletions(-) create mode 100644 __tests__/polyfills/abortSignal.test.ts create mode 100644 __tests__/polyfills/xhr.test.ts create mode 100644 src/polyfills/abortSignal.ts diff --git a/__tests__/polyfills/abortSignal.test.ts b/__tests__/polyfills/abortSignal.test.ts new file mode 100644 index 000000000..40d6d28ff --- /dev/null +++ b/__tests__/polyfills/abortSignal.test.ts @@ -0,0 +1,106 @@ +/** + * AbortSignal static-method Polyfill Tests + * + * Hermes lacks AbortSignal.timeout() and AbortSignal.any(). The Stellar SDK's + * feaxios HTTP client calls both, which broke every transaction submit with + * "undefined is not a function" (Sentry FREIGHTER-MOBILE-YN). + * + * Jest (V8/Node) already provides these natively, so each test deletes them + * first to reproduce the Hermes gap, then loads the polyfill and asserts it + * restores the missing behavior. + * + * The project's TypeScript lib does not declare these members, so we view the + * AbortSignal constructor through a local shim type. + */ +type AbortSignalStatics = { + timeout?: (ms: number) => AbortSignal; + any?: (signals: Iterable) => AbortSignal; + abort: (reason?: unknown) => AbortSignal; +}; + +const statics = AbortSignal as unknown as AbortSignalStatics; +const reasonOf = (signal: AbortSignal): unknown => + (signal as AbortSignal & { reason: unknown }).reason; + +const loadPolyfill = () => { + jest.isolateModules(() => { + // eslint-disable-next-line global-require + require("../../src/polyfills/abortSignal"); + }); +}; + +describe("AbortSignal polyfill", () => { + const originalTimeout = statics.timeout; + const originalAny = statics.any; + + afterEach(() => { + statics.timeout = originalTimeout; + statics.any = originalAny; + jest.useRealTimers(); + }); + + it("installs AbortSignal.timeout when missing", () => { + delete statics.timeout; + expect(statics.timeout).toBeUndefined(); + + loadPolyfill(); + + expect(typeof statics.timeout).toBe("function"); + }); + + it("AbortSignal.timeout returns a signal that aborts after the delay", () => { + jest.useFakeTimers(); + delete statics.timeout; + loadPolyfill(); + + const signal = statics.timeout!(1000); + expect(signal).toBeInstanceOf(AbortSignal); + expect(signal.aborted).toBe(false); + + jest.advanceTimersByTime(1000); + + expect(signal.aborted).toBe(true); + expect((reasonOf(signal) as Error).name).toBe("TimeoutError"); + }); + + it("installs AbortSignal.any when missing", () => { + delete statics.any; + expect(statics.any).toBeUndefined(); + + loadPolyfill(); + + expect(typeof statics.any).toBe("function"); + }); + + it("AbortSignal.any aborts when one of its inputs aborts", () => { + delete statics.any; + loadPolyfill(); + + const controller = new AbortController() as AbortController & { + abort: (reason?: unknown) => void; + }; + const combined = statics.any!([controller.signal]); + expect(combined.aborted).toBe(false); + + controller.abort(new Error("boom")); + + expect(combined.aborted).toBe(true); + expect((reasonOf(combined) as Error).message).toBe("boom"); + }); + + it("AbortSignal.any aborts immediately if an input is already aborted", () => { + delete statics.any; + loadPolyfill(); + + const combined = statics.any!([statics.abort(new Error("pre"))]); + + expect(combined.aborted).toBe(true); + }); + + it("does not override existing native implementations", () => { + const sentinelTimeout = originalTimeout; + loadPolyfill(); + + expect(statics.timeout).toBe(sentinelTimeout); + }); +}); diff --git a/__tests__/polyfills/xhr.test.ts b/__tests__/polyfills/xhr.test.ts new file mode 100644 index 000000000..b9f50ba43 --- /dev/null +++ b/__tests__/polyfills/xhr.test.ts @@ -0,0 +1,70 @@ +/** + * XMLHttpRequest Polyfill Tests + * + * Verifies the two RN/Stellar-SDK compatibility patches in src/polyfills/xhr.ts: + * - URLSearchParams request bodies are stringified before send (RN's XHR can't + * serialize the object, which made SDK v16 form-urlencoded submits go out + * empty → Horizon transaction_malformed). + * - the exported normalizeXhrRequestBody helper. + * + * A stub XMLHttpRequest is installed so the test exercises the patch regardless + * of the jest environment's XHR. + */ +type SendArg = unknown; + +describe("xhr polyfill", () => { + const originalXHR = global.XMLHttpRequest; + let sentBodies: SendArg[]; + let normalizeXhrRequestBody: (body?: SendArg) => SendArg; + + beforeEach(() => { + sentBodies = []; + + class StubXMLHttpRequest { + // eslint-disable-next-line class-methods-use-this + send(body?: SendArg): void { + sentBodies.push(body); + } + } + + (global as unknown as { XMLHttpRequest: unknown }).XMLHttpRequest = + StubXMLHttpRequest; + + jest.isolateModules(() => { + // eslint-disable-next-line global-require, @typescript-eslint/no-var-requires + ({ normalizeXhrRequestBody } = require("../../src/polyfills/xhr")); + }); + }); + + afterEach(() => { + (global as unknown as { XMLHttpRequest: unknown }).XMLHttpRequest = + originalXHR; + }); + + it("send converts a URLSearchParams body to its string form", () => { + const xhr = new global.XMLHttpRequest(); + const params = new URLSearchParams("tx=AAAA%2B%2F%3D%3D"); + + xhr.send(params); + + expect(sentBodies).toHaveLength(1); + expect(typeof sentBodies[0]).toBe("string"); + expect(sentBodies[0]).toBe(params.toString()); + }); + + it("send passes a plain string body through untouched", () => { + const xhr = new global.XMLHttpRequest(); + + xhr.send("tx=already-encoded"); + + expect(sentBodies[0]).toBe("tx=already-encoded"); + }); + + it("normalizeXhrRequestBody stringifies URLSearchParams and passes other bodies through", () => { + const params = new URLSearchParams("a=1&b=2"); + expect(normalizeXhrRequestBody(params)).toBe(params.toString()); + expect(normalizeXhrRequestBody("raw")).toBe("raw"); + expect(normalizeXhrRequestBody(null)).toBeNull(); + expect(normalizeXhrRequestBody(undefined)).toBeUndefined(); + }); +}); diff --git a/src/bootstrap.js b/src/bootstrap.js index 1e73d32c4..bc40e2554 100644 --- a/src/bootstrap.js +++ b/src/bootstrap.js @@ -10,5 +10,9 @@ // XHR polyfill for React Native - fixes Stellar SDK compatibility issues require("./polyfills/xhr"); +// AbortSignal.timeout/any polyfill - Hermes lacks these statics, which the +// Stellar SDK's feaxios HTTP client needs for transaction submission. +require("./polyfills/abortSignal"); + // Export nothing - this file is used only for side effects module.exports = {}; diff --git a/src/polyfills/abortSignal.ts b/src/polyfills/abortSignal.ts new file mode 100644 index 000000000..ea0b6013f --- /dev/null +++ b/src/polyfills/abortSignal.ts @@ -0,0 +1,75 @@ +/** + * AbortSignal static-method Polyfill for React Native (Hermes) + * + * Hermes implements `AbortController`/`AbortSignal` but not the static helpers + * `AbortSignal.timeout()` and `AbortSignal.any()`. The Stellar SDK (v16+) routes + * HTTP through `feaxios`, whose `handleFetch` calls both — and `submitTransaction` + * is the only request that passes a `timeout`, so every transaction submit threw + * "undefined is not a function" while GET requests (balances/history) worked. + * + * Each method is guarded so this no-ops on runtimes that already provide them. + * + * The project's TypeScript lib does not declare these newer members, so we view + * the globals through local shims rather than relying on the DOM lib types. + */ + +interface AbortControllerLike { + readonly signal: AbortSignal; + abort(reason?: unknown): void; +} + +type AbortSignalLike = AbortSignal & { readonly reason: unknown }; + +type AbortSignalStatics = { + timeout?: (milliseconds: number) => AbortSignal; + any?: (signals: Iterable) => AbortSignal; +}; + +const AbortSignalCtor = AbortSignal as unknown as AbortSignalStatics; + +// Abort reason for timeouts, matching the web spec's TimeoutError where possible. +const makeTimeoutReason = (): unknown => { + if (typeof DOMException === "function") { + return new DOMException("The operation timed out.", "TimeoutError"); + } + + const error = new Error("The operation timed out."); + error.name = "TimeoutError"; + return error; +}; + +if (typeof AbortSignal !== "undefined") { + if (typeof AbortSignalCtor.timeout !== "function") { + AbortSignalCtor.timeout = (milliseconds: number): AbortSignal => { + const controller = + new AbortController() as unknown as AbortControllerLike; + setTimeout(() => controller.abort(makeTimeoutReason()), milliseconds); + return controller.signal; + }; + } + + if (typeof AbortSignalCtor.any !== "function") { + AbortSignalCtor.any = (signals: Iterable): AbortSignal => { + const controller = + new AbortController() as unknown as AbortControllerLike; + const signalArray = Array.from(signals) as AbortSignalLike[]; + const alreadyAborted = signalArray.find((signal) => signal.aborted); + + if (alreadyAborted) { + controller.abort(alreadyAborted.reason); + } else { + signalArray.forEach((signal) => { + signal.addEventListener( + "abort", + () => controller.abort(signal.reason), + { once: true }, + ); + }); + } + + return controller.signal; + }; + } +} + +export {}; diff --git a/src/polyfills/xhr.ts b/src/polyfills/xhr.ts index 40d6b650e..9dfa0c4fd 100644 --- a/src/polyfills/xhr.ts +++ b/src/polyfills/xhr.ts @@ -1,9 +1,18 @@ /** * XMLHttpRequest Polyfill for React Native * - * This polyfill fixes issues with the Stellar SDK trying to use browser-specific - * responseType values ('ms-stream' and 'moz-chunked-arraybuffer') that are not - * supported in React Native. + * Two fixes, both for Stellar SDK HTTP compatibility: + * + * 1. responseType — the SDK sets browser-specific responseType values + * ('ms-stream', 'moz-chunked-arraybuffer') that React Native doesn't support. + * + * 2. URLSearchParams request bodies — the SDK (v16+) routes HTTP through feaxios, + * whose transformer converts a form-urlencoded string body into a + * URLSearchParams object. React Native's fetch (whatwg-fetch) is XHR-based and + * calls `xhr.send(urlSearchParams)` with the object; RN's convertRequestBody + * only serializes string/Blob/FormData/ArrayBuffer, so the body went out empty + * and Horizon rejected every transaction submit as `transaction_malformed` + * (empty envelope_xdr). Sending the string form fixes it. */ // Store reference to the original XMLHttpRequest @@ -12,6 +21,26 @@ const OriginalXMLHttpRequest = global.XMLHttpRequest; // List of unsupported responseTypes const UNSUPPORTED_RESPONSE_TYPES = ["ms-stream", "moz-chunked-arraybuffer"]; +// Derived from the actual send() signature so we don't depend on DOM lib type +// names (e.g. XMLHttpRequestBodyInit) that React Native's types omit. +type XhrBody = Parameters[0]; + +/** + * Normalizes an XHR request body for React Native: a URLSearchParams body is + * converted to its string form (RN's XHR cannot serialize the object). All other + * body types pass through untouched. + */ +export const normalizeXhrRequestBody = (body?: XhrBody): XhrBody => { + if ( + typeof URLSearchParams !== "undefined" && + body instanceof URLSearchParams + ) { + return body.toString(); + } + + return body; +}; + // Create a patched version of XMLHttpRequest class PatchedXMLHttpRequest extends OriginalXMLHttpRequest { // Override the responseType setter to filter out unsupported values @@ -30,9 +59,12 @@ class PatchedXMLHttpRequest extends OriginalXMLHttpRequest { get responseType(): XMLHttpRequestResponseType { return super.responseType; } + + // Convert URLSearchParams bodies to a string RN can serialize + send(body?: XhrBody): void { + super.send(normalizeXhrRequestBody(body)); + } } // Replace the global XMLHttpRequest with our patched version global.XMLHttpRequest = PatchedXMLHttpRequest; - -export {}; From 3f52a5d817a69a881dfdbefc6bd8d4501847095a Mon Sep 17 00:00:00 2001 From: Aristides Staffieri Date: Wed, 17 Jun 2026 08:26:51 -0600 Subject: [PATCH 4/4] chore: upgrade @stellar/stellar-sdk to 16.0.0 stable Bump the direct dependency from 16.0.0-rc.1 to the stable 16.0.0 release. The dependency set is identical between the two (axios 1.16.1, bignumber.js ^11.1.1, eventsource ^4.1.0, @noble/*), so this is a version-label change with no transitive churn. The branch already ran the rc with the metro/bignumber-v11 fix and the RN transaction-submission fix in place; tsc --noEmit and the full jest suite (146 suites, 2033 tests) pass against stable. --- package.json | 2 +- yarn.lock | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index 2b6e7414d..174a20c11 100644 --- a/package.json +++ b/package.json @@ -85,7 +85,7 @@ "@shopify/react-native-skia": "2.2.21", "@stablelib/base64": "2.0.1", "@stablelib/utf8": "2.0.1", - "@stellar/stellar-sdk": "16.0.0-rc.1", + "@stellar/stellar-sdk": "16.0.0", "@stellar/typescript-wallet-sdk-km": "3.0.0", "@tradle/react-native-http": "2.0.0", "@walletconnect/core": "2.23.1", diff --git a/yarn.lock b/yarn.lock index b356de0b2..82ee862f3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4732,9 +4732,9 @@ __metadata: languageName: node linkType: hard -"@stellar/stellar-sdk@npm:16.0.0-rc.1": - version: 16.0.0-rc.1 - resolution: "@stellar/stellar-sdk@npm:16.0.0-rc.1" +"@stellar/stellar-sdk@npm:16.0.0": + version: 16.0.0 + resolution: "@stellar/stellar-sdk@npm:16.0.0" dependencies: "@noble/ed25519": "npm:^3.1.0" "@noble/hashes": "npm:^2.2.0" @@ -4750,7 +4750,7 @@ __metadata: uint8array-extras: "npm:^1.5.0" bin: stellar-js: bin/stellar-js - checksum: 10c0/0541092c5c5819d781b6359315a47785757f1c6a0318435b2183c8445b339ecaad147305ce8e204bb0f3ba5ce5bf0e27a491911ac59b7d9262858dfd413687a0 + checksum: 10c0/d3aa09d009787e147c9a3da4b4bf39d15001112cbd968d658aaea0d32a4c5ed3b8f3bd92b1c76b925a253f4e2c0c718a9737e49367be74e6f5683d9b365bf8a1 languageName: node linkType: hard @@ -9932,7 +9932,7 @@ __metadata: "@shopify/react-native-skia": "npm:2.2.21" "@stablelib/base64": "npm:2.0.1" "@stablelib/utf8": "npm:2.0.1" - "@stellar/stellar-sdk": "npm:16.0.0-rc.1" + "@stellar/stellar-sdk": "npm:16.0.0" "@stellar/typescript-wallet-sdk-km": "npm:3.0.0" "@testing-library/jest-native": "npm:5.4.3" "@testing-library/react-hooks": "npm:8.0.1"