Skip to content
Merged
Show file tree
Hide file tree
Changes from 21 commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
c7e88e8
enable sending to federated address
leofelix077 Apr 15, 2026
05eef5f
adjust clearing search state and debounce
leofelix077 Apr 17, 2026
679d22f
Merge branch 'main' into lf-enable-send-federated-address
leofelix077 Apr 17, 2026
01831d7
carry forward federated address on contacts and review
leofelix077 Apr 17, 2026
0d750e3
Merge branch 'lf-enable-send-federated-address' of github.com:stellar…
leofelix077 Apr 17, 2026
3856256
add tests to CI
leofelix077 Apr 17, 2026
43a3309
update shard-total
leofelix077 Apr 17, 2026
6a9b311
harden federated address validation and e2e flow for usdc and xlm
leofelix077 Apr 23, 2026
411ae5a
Merge branch 'main' into lf-enable-send-federated-address
leofelix077 Apr 23, 2026
24629d4
add loading state for search and parity validation with extension
leofelix077 Apr 23, 2026
6210d3d
Merge branch 'lf-enable-send-federated-address' of github.com:stellar…
leofelix077 Apr 23, 2026
a114efb
Merge branch 'main' into lf-enable-send-federated-address
leofelix077 Apr 23, 2026
babf8b1
truncate federated address and memo type inference
leofelix077 Apr 23, 2026
90cb793
remove extra text on required by recipient
leofelix077 Apr 23, 2026
d7f5778
fix e2e test
leofelix077 Apr 23, 2026
6ec12d7
fix e2e test
leofelix077 Apr 23, 2026
716c0e2
fix e2e test
leofelix077 Apr 24, 2026
432f10e
harden federated address ascii validation
leofelix077 Apr 24, 2026
9dcf54a
fix federation address validation regex
leofelix077 Apr 24, 2026
40178cd
Merge remote-tracking branch 'origin/main' into lf-enable-send-federa…
leofelix077 Apr 27, 2026
34dd9cf
update address type distinction and memo validation
leofelix077 Apr 27, 2026
7918bc3
Remove unused ContactType enum and type property
leofelix077 May 5, 2026
926531a
Merge main into lf-enable-send-federated-address
leofelix077 May 5, 2026
efdb30c
merge main into branch and fix conflicts
leofelix077 May 5, 2026
0741bec
Merge branch 'main' into lf-enable-send-federated-address
leofelix077 May 11, 2026
a4281a0
Merge branch 'lf-enable-send-federated-address' of github.com:stellar…
leofelix077 May 12, 2026
2415b0f
fix merge conflicts
leofelix077 May 12, 2026
ac64f71
revert merge conflicts changes
leofelix077 May 12, 2026
75fa6b5
Merge branch 'main' into lf-enable-send-federated-address
leofelix077 May 18, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 15 additions & 10 deletions .github/workflows/android-e2e.yml
Original file line number Diff line number Diff line change
Expand Up @@ -232,50 +232,55 @@ jobs:
# - SendClassicTokenMainnet: Send XLM on mainnet (small amount: 0.000001 XLM)
# - SwapClassicTokenMainnet: Swap tokens on mainnet (small amount: 0.000001 XLM)
# - ForgotPasswordWarning: Lock screen forgot password confirmation modal
# - SendFederatedAddress: Send XLM and USDC to a federated address on mainnet (small amount: 0.000001 each)
# - SignMessageMockDapp: WalletConnect sign message (requires mock server)
# - SignAuthEntryMockDapp: WalletConnect sign auth entry (requires mock server)
# Note: mock-dapp tests use shard-index:0, to run sequentially in isolation,
# to avoid e2e test flakiness related to starting/stopping the mock server and potential port conflicts.
matrix:
include:
- shard-index: 0
shard-total: 8
shard-total: 9
flow-name: LaunchAndInspect
requires-mock-server: false
- shard-index: 1
shard-total: 8
shard-total: 9
flow-name: CreateWallet
requires-mock-server: false
- shard-index: 2
shard-total: 8
shard-total: 9
flow-name: ImportWallet
requires-mock-server: false
- shard-index: 3
shard-total: 8
shard-total: 9
flow-name: ImportFundedWallet
requires-mock-server: false
- shard-index: 4
shard-total: 8
shard-total: 9
flow-name: SwitchToTestnet
requires-mock-server: false
- shard-index: 5
shard-total: 8
shard-total: 9
flow-name: SendClassicTokenMainnet
requires-mock-server: false
- shard-index: 6
shard-total: 8
shard-total: 9
flow-name: SwapClassicTokenMainnet
requires-mock-server: false
- shard-index: 7
shard-total: 8
shard-total: 9
flow-name: ForgotPasswordWarning
requires-mock-server: false
- shard-index: 8
shard-total: 9
flow-name: SendFederatedAddress
requires-mock-server: false
- shard-index: 0
shard-total: 8
shard-total: 9
flow-name: SignMessageMockDapp
requires-mock-server: true
- shard-index: 0
shard-total: 8
shard-total: 9
flow-name: SignAuthEntryMockDapp
requires-mock-server: true

Expand Down
25 changes: 15 additions & 10 deletions .github/workflows/ios-e2e.yml
Original file line number Diff line number Diff line change
Expand Up @@ -229,50 +229,55 @@ jobs:
# - SendClassicTokenMainnet: Send XLM on mainnet (small amount: 0.000001 XLM)
# - SwapClassicTokenMainnet: Swap tokens on mainnet (small amount: 0.000001 XLM)
# - ForgotPasswordWarning: Lock screen forgot password confirmation modal
# - SendFederatedAddress: Send XLM and USDC to a federated address on mainnet (small amount: 0.000001 each)
# - SignMessageMockDapp: WalletConnect sign message (requires mock server)
# - SignAuthEntryMockDapp: WalletConnect sign auth entry (requires mock server)
# Note: mock-dapp tests use shard-index:0, to run sequentially in isolation,
# to avoid e2e test flakiness related to starting/stopping the mock server and potential port conflicts.
matrix:
include:
- shard-index: 0
shard-total: 8
shard-total: 9
flow-name: LaunchAndInspect
requires-mock-server: false
- shard-index: 1
shard-total: 8
shard-total: 9
flow-name: CreateWallet
requires-mock-server: false
- shard-index: 2
shard-total: 8
shard-total: 9
flow-name: ImportWallet
requires-mock-server: false
- shard-index: 3
shard-total: 8
shard-total: 9
flow-name: ImportFundedWallet
requires-mock-server: false
- shard-index: 4
shard-total: 8
shard-total: 9
flow-name: SwitchToTestnet
requires-mock-server: false
- shard-index: 5
shard-total: 8
shard-total: 9
flow-name: SendClassicTokenMainnet
requires-mock-server: false
- shard-index: 6
shard-total: 8
shard-total: 9
flow-name: SwapClassicTokenMainnet
requires-mock-server: false
- shard-index: 7
shard-total: 8
shard-total: 9
flow-name: ForgotPasswordWarning
requires-mock-server: false
- shard-index: 8
shard-total: 9
flow-name: SendFederatedAddress
requires-mock-server: false
- shard-index: 0
shard-total: 8
shard-total: 9
flow-name: SignMessageMockDapp
requires-mock-server: true
- shard-index: 0
shard-total: 8
shard-total: 9
flow-name: SignAuthEntryMockDapp
requires-mock-server: true

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ jest.mock("@gorhom/bottom-sheet", () => ({
// Mock stellar helpers
jest.mock("helpers/stellar", () => ({
isValidStellarAddress: jest.fn().mockReturnValue(true),
isFederationAddress: jest.fn().mockReturnValue(false),
truncateAddress: jest.fn(
(address) => `${address.slice(0, 4)}...${address.slice(-4)}`,
),
Expand Down Expand Up @@ -74,10 +75,16 @@ const getSendStoreMock = (overrides = {}) =>
recentAddresses: [],
searchResults: [],
searchError: null,
isSearching: false,
isValidDestination: false,
isDestinationFunded: null,
federationMemo: "",
federationMemoType: "",
loadRecentAddresses: mockLoadRecentAddresses,
searchAddress: mockSearchAddress,
addRecentAddress: mockAddRecentAddress,
setDestinationAddress: mockSetDestinationAddress,
prepareForSearch: jest.fn(),
resetSendRecipient: mockReset,
Comment on lines 86 to 90
Copy link

Copilot AI Apr 21, 2026

Choose a reason for hiding this comment

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

The SendSearchContacts tests’ useSendRecipientStore mock only adds clearSearchState, but the component now also reads isSearching, isValidDestination, and isDestinationFunded from the store. The mock should provide realistic defaults for these fields to avoid the component taking different render paths in tests vs production. Additionally, the module mock for helpers/stellar should export isFederationAddress now that the component imports/uses it; otherwise any test that triggers handleContactPress will throw. Finally, since search is now debounced via setTimeout, assertions that searchAddress was called may need fake timers / advancing timers to avoid flakiness.

Copilot uses AI. Check for mistakes.
...overrides,
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -338,12 +338,15 @@ describe("TransactionAmountScreen - Memo Update Flow", () => {
transactionFee: "0.00001",
transactionTimeout: 30,
recipientAddress: mockRecipientAddress,
federationAddress: "",
selectedTokenId:
"USDC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN",
saveMemo: jest.fn(),
saveMemoType: jest.fn(),
saveTransactionFee: jest.fn(),
saveTransactionTimeout: jest.fn(),
saveRecipientAddress: jest.fn(),
saveFederationAddress: jest.fn(),
saveSelectedTokenId: jest.fn(),
saveSelectedCollectibleDetails: jest.fn(),
resetSettings: jest.fn(),
Expand Down Expand Up @@ -944,12 +947,15 @@ describe("TransactionAmountScreen - Address Change Scenarios", () => {
transactionFee: "0.00001",
transactionTimeout: 30,
recipientAddress: mockNonMemoRequiredAddress,
federationAddress: "",
selectedTokenId:
"USDC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN",
saveMemo: jest.fn(),
saveMemoType: jest.fn(),
saveTransactionFee: jest.fn(),
saveTransactionTimeout: jest.fn(),
saveRecipientAddress: jest.fn(),
saveFederationAddress: jest.fn(),
saveSelectedTokenId: jest.fn(),
saveSelectedCollectibleDetails: jest.fn(),
resetSettings: jest.fn(),
Expand Down
131 changes: 125 additions & 6 deletions __tests__/ducks/sendRecipient.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { Federation } from "@stellar/stellar-sdk";
import { Federation, StrKey } from "@stellar/stellar-sdk";
import { act } from "@testing-library/react-hooks";
import { STORAGE_KEYS } from "config/constants";
import { getActiveAccountPublicKey } from "ducks/auth";
import { useSendRecipientStore } from "ducks/sendRecipient";
import { ContactType, useSendRecipientStore } from "ducks/sendRecipient";
import * as stellarHelpers from "helpers/stellar";
import { getAccount } from "services/stellar";
import { dataStorage } from "services/storage/storageFactory";
Expand Down Expand Up @@ -44,6 +44,9 @@ jest.mock("@stellar/stellar-sdk", () => ({
resolve: jest.fn(),
},
},
StrKey: {
isValidEd25519PublicKey: jest.fn(),
},
Networks: jest.requireActual("@stellar/stellar-sdk").Networks,
}));

Expand Down Expand Up @@ -82,6 +85,8 @@ describe("sendRecipient Duck", () => {
searchResults: [],
destinationAddress: "",
federationAddress: "",
federationMemo: "",
federationMemoType: "",
isSearching: false,
searchError: null,
isValidDestination: false,
Expand All @@ -97,6 +102,7 @@ describe("sendRecipient Duck", () => {
(stellarHelpers.isFederationAddress as jest.Mock).mockReturnValue(false);
(getAccount as jest.Mock).mockResolvedValue({ id: mockPublicKey });
(getActiveAccountPublicKey as jest.Mock).mockResolvedValue("DIFFERENT_KEY");
(StrKey.isValidEd25519PublicKey as jest.Mock).mockReturnValue(true);
});

it("should have correct initial state", () => {
Expand Down Expand Up @@ -139,14 +145,21 @@ describe("sendRecipient Duck", () => {
expect(dataStorage.setItem).toHaveBeenCalled();
});

it("should not add duplicate address", async () => {
it("should not write to storage when address already exists with the same name", async () => {
act(() => {
store.setState({
recentAddresses: [{ id: "recent-1", address: "existingAddress" }],
recentAddresses: [
{
id: "recent-1",
address: "existingAddress",
name: "alice*fed.com",
type: ContactType.Federation,
},
],
});
});

await store.getState().addRecentAddress("existingAddress");
await store.getState().addRecentAddress("existingAddress", "alice*fed.com");

expect(dataStorage.setItem).not.toHaveBeenCalled();
});
Expand All @@ -170,6 +183,110 @@ describe("sendRecipient Duck", () => {

expect(store.getState().destinationAddress).toBe(mockPublicKey);
expect(store.getState().federationAddress).toBe(mockFederationAddress);
expect(store.getState().federationMemo).toBe("");
expect(store.getState().federationMemoType).toBe("");
});

it("should capture federation memo and memo_type when server returns them", async () => {
(stellarHelpers.isFederationAddress as jest.Mock).mockReturnValue(true);
(Federation.Server.resolve as jest.Mock).mockResolvedValue({
account_id: mockPublicKey,
memo: "12345",
memo_type: "id",
});

await store.getState().searchAddress(mockFederationAddress);

expect(store.getState().destinationAddress).toBe(mockPublicKey);
expect(store.getState().federationMemo).toBe("12345");
expect(store.getState().federationMemoType).toBe("id");
});

it("should error when federation server returns a malformed account_id", async () => {
(stellarHelpers.isFederationAddress as jest.Mock).mockReturnValue(true);
(Federation.Server.resolve as jest.Mock).mockResolvedValue({
account_id: "not-a-valid-key",
});
(StrKey.isValidEd25519PublicKey as jest.Mock).mockReturnValue(false);

await store.getState().searchAddress(mockFederationAddress);

expect(store.getState().searchError).toBe(
"sendRecipient.error.federationNotFound",
);
expect(store.getState().isValidDestination).toBe(false);
expect(store.getState().destinationAddress).toBe("");
});

it("should error when Federation.Server.resolve rejects", async () => {
(stellarHelpers.isFederationAddress as jest.Mock).mockReturnValue(true);
(Federation.Server.resolve as jest.Mock).mockRejectedValue(
new Error("DNS failure"),
);

await store.getState().searchAddress(mockFederationAddress);

expect(store.getState().searchError).toBe(
"sendRecipient.error.federationNotFound",
);
expect(store.getState().isSearching).toBe(false);
expect(store.getState().isValidDestination).toBe(false);
});

it("should error when resolved federation address is the user's own account", async () => {
(stellarHelpers.isFederationAddress as jest.Mock).mockReturnValue(true);
(Federation.Server.resolve as jest.Mock).mockResolvedValue({
account_id: mockPublicKey,
});
(getActiveAccountPublicKey as jest.Mock).mockResolvedValue(mockPublicKey);
// First call: federation string vs own key → false (different format, no match)
// Second call: resolved G... key vs own key → true (it is the same account)
(stellarHelpers.isSameAccount as jest.Mock)
.mockReturnValueOnce(false)
.mockReturnValueOnce(true);

await store.getState().searchAddress(mockFederationAddress);

expect(store.getState().searchError).toBe(
"sendRecipient.error.sendToSelfFederation",
);
expect(store.getState().isValidDestination).toBe(false);
});

it("should call Federation.Server.resolve with a 10 second timeout", async () => {
(stellarHelpers.isFederationAddress as jest.Mock).mockReturnValue(true);
(Federation.Server.resolve as jest.Mock).mockResolvedValue({
account_id: mockPublicKey,
});

await store.getState().searchAddress(mockFederationAddress);

expect(Federation.Server.resolve).toHaveBeenCalledWith(
mockFederationAddress,
{ timeout: 10000 },
);
});

it("should update federation name on existing recent address", async () => {
act(() => {
store.setState({
recentAddresses: [
{
id: "recent-1",
address: "existingAddress",
type: ContactType.Address,
},
],
});
});

await store.getState().addRecentAddress("existingAddress", "alice*fed.com");

const updated = store
.getState()
.recentAddresses.find((c) => c.address === "existingAddress");
expect(updated?.name).toBe("alice*fed.com");
expect(dataStorage.setItem).toHaveBeenCalled();
});

it("should handle invalid address format", async () => {
Expand Down Expand Up @@ -212,7 +329,9 @@ describe("sendRecipient Duck", () => {
it("should reset all search-related state", () => {
act(() => {
store.setState({
searchResults: [{ id: "1", address: "address" }],
searchResults: [
{ id: "1", address: "address", type: ContactType.Address },
],
destinationAddress: "address",
federationAddress: "fed*address",
isSearching: true,
Expand Down
Loading
Loading