Skip to content

Commit 5c0fd71

Browse files
authored
Fix Feed page to only show Allowance to Private wallet payments (#125) (#136)
* Updated the code to only display payments from allowance wallet to private * Fixed the code review comments for logging, Null check and Missing Receiver Match * fixed the Performance, Wallet tracking and Null saftey checks comments * Code review comments better quality and Error in Transaction Filtering
1 parent 92bb0b6 commit 5c0fd71

File tree

4 files changed

+140
-114
lines changed

4 files changed

+140
-114
lines changed

tabs/backend/data.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
{
2-
"rewardName": "coins"
2+
"rewardName": "sats"
33
}

tabs/src/Feed.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,7 @@ const Home: React.FC = () => {
103103
paddingTop: 0,
104104
}}
105105
>
106-
<FeedComponent isLoading={loading} allZaps={zaps} allUsers={users} />
106+
<FeedComponent />
107107
</div>
108108
</div>
109109
);

tabs/src/components/FeedComponent.tsx

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,8 @@ import styles from './FeedComponent.module.css';
33
import FeedList from './FeedList';
44

55
const Leaderboard = lazy(() => import('./Leaderboard'));
6-
interface FeedComponentProps {
7-
allZaps: Transaction[];
8-
allUsers: User[];
9-
isLoading: boolean;
10-
}
116

12-
const FeedComponent: FunctionComponent<FeedComponentProps> = ({ isLoading, allZaps, allUsers }) => {
7+
const FeedComponent: FunctionComponent = () => {
138
const [timestamp, setTimestamp] = useState(
149
Math.floor(Date.now() / 1000 - 7 * 24 * 60 * 60),
1510
);
@@ -95,7 +90,7 @@ const FeedComponent: FunctionComponent<FeedComponentProps> = ({ isLoading, allZa
9590
</div>
9691
</div>
9792
{showFeed ? (
98-
<FeedList isLoading={isLoading} timestamp={timestamp} allZaps={allZaps} allUsers={allUsers} />
93+
<FeedList timestamp={timestamp} />
9994
) : (
10095
<Suspense fallback={<div>Loading...</div>}>
10196
<Leaderboard timestamp={timestamp} />

tabs/src/components/FeedList.tsx

Lines changed: 136 additions & 105 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,6 @@ import {
99

1010
interface FeedListProps {
1111
timestamp?: number | null;
12-
allZaps?: Transaction[];
13-
allUsers?: User[];
14-
isLoading?: boolean;
1512
}
1613
interface ZapTransaction {
1714
from: User | null;
@@ -23,125 +20,128 @@ interface ZapTransaction {
2320
const ITEMS_PER_PAGE = 10; // Items per page
2421
const MAX_RECORDS = 100; // Maximum records to display
2522

26-
const FeedList: React.FC<FeedListProps> = ({
27-
timestamp,
28-
allZaps = [],
29-
allUsers = [],
30-
isLoading = false
31-
}) => {
23+
// Wallet type identifiers - these match the exact naming convention used by the backend
24+
// Backend creates wallets with names 'Allowance' and 'Private' (see functions/sendZap/index.ts)
25+
// NOTE: If wallet naming conventions change on the backend, these must be updated
26+
const WALLET_NAME_ALLOWANCE = 'Allowance';
27+
const WALLET_NAME_PRIVATE = 'Private';
28+
29+
// Helper functions to identify wallet types by name
30+
// Using exact match (case-insensitive) to avoid false positives like "not_an_allowance_wallet"
31+
const isAllowanceWallet = (walletName: string): boolean =>
32+
walletName.toLowerCase() === WALLET_NAME_ALLOWANCE.toLowerCase();
33+
34+
const isPrivateWallet = (walletName: string): boolean =>
35+
walletName.toLowerCase() === WALLET_NAME_PRIVATE.toLowerCase();
36+
37+
const FeedList: React.FC<FeedListProps> = ({ timestamp }) => {
3238
const [zaps, setZaps] = useState<ZapTransaction[]>([]);
33-
const [users, setUsers] = useState<User[]>([]);
3439
const [loading, setLoading] = useState(true);
3540
const [error, setError] = useState<string | null>(null);
3641
const [currentPage, setCurrentPage] = useState(1);
3742
const initialRender = useRef(true);
3843

39-
// NEW: State for sorting (excluding the Memo field)
40-
const [sortField, setSortField] = useState<'time' | 'from' | 'to' | 'amount'>(
41-
'time',
42-
);
44+
// State for sorting (excluding the Memo field)
45+
const [sortField, setSortField] = useState<'time' | 'from' | 'to' | 'amount'>('time');
4346
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc');
4447

4548
// Get admin key from environment
46-
const adminKey = process.env.REACT_APP_LNBITS_ADMINKEY as string;
49+
const adminKey = process.env.REACT_APP_LNBITS_ADMINKEY;
4750

4851
useEffect(() => {
4952
const fetchZapsStepByStep = async () => {
5053
setLoading(true);
5154
setError(null);
5255

5356
try {
57+
// Validate adminKey is configured
58+
if (!adminKey) {
59+
setError('Configuration error: Admin key not set.');
60+
setLoading(false);
61+
return;
62+
}
63+
5464
const paymentsSinceTimestamp =
5565
timestamp === null || timestamp === undefined || timestamp === 0
5666
? 0
5767
: timestamp;
5868

59-
// Step 1: Get all users from /users/api/v1/user
69+
// Step 1: Get all users
6070
const fetchedUsers = await getUsers(adminKey, {});
6171
if (!fetchedUsers || fetchedUsers.length === 0) {
6272
setError('Unable to load users. Please check your connection and try again.');
6373
setLoading(false);
6474
return;
6575
}
66-
setUsers(fetchedUsers);
6776

68-
// Step 2: For each user, get wallets using /users/api/v1/user/{userId}/wallet
77+
// Step 2: Get wallets for each user
6978
const allWalletsData: { userId: string; wallets: Wallet[] }[] = [];
70-
const allWalletsArray: Wallet[] = [];
7179

7280
for (const user of fetchedUsers) {
7381
const userWallets = await getUserWallets(adminKey, user.id);
74-
const wallets = userWallets || [];
75-
7682
allWalletsData.push({
7783
userId: user.id,
78-
wallets: wallets
84+
wallets: userWallets || []
7985
});
80-
allWalletsArray.push(...wallets);
8186
}
82-
// Step 3: For each wallet, get payments from Private and Allowance wallets only
83-
let allPayments: Transaction[] = [];
87+
// Step 3: Get payments from both Allowance and Private wallets
88+
// We need both to match sender (Allowance) with receiver (Private)
89+
const allowanceWalletIds = new Set<string>();
90+
const privateWalletIds = new Set<string>();
91+
const allRelevantWallets: Wallet[] = [];
92+
let failedWalletCount = 0;
8493

8594
for (const userData of allWalletsData) {
86-
// Filter to only Private and Allowance wallets
87-
const filteredWallets = userData.wallets.filter(wallet => {
88-
const walletName = wallet.name.toLowerCase();
89-
return walletName.includes('private') || walletName.includes('allowance');
95+
// Filter to Allowance and Private wallets
96+
const relevantWallets = userData.wallets.filter(wallet =>
97+
isAllowanceWallet(wallet.name) || isPrivateWallet(wallet.name)
98+
);
99+
100+
// Track wallet IDs by type
101+
relevantWallets.forEach(wallet => {
102+
if (isAllowanceWallet(wallet.name)) {
103+
allowanceWalletIds.add(wallet.id);
104+
}
105+
if (isPrivateWallet(wallet.name)) {
106+
privateWalletIds.add(wallet.id);
107+
}
90108
});
91109

92-
// Get payments from filtered wallets only
93-
for (const wallet of filteredWallets) {
94-
try {
95-
const payments = await getWalletTransactionsSince(
96-
wallet.inkey,
97-
paymentsSinceTimestamp,
98-
null
99-
);
100-
allPayments = allPayments.concat(payments);
101-
} catch (err) {
102-
console.error(`Error fetching payments for wallet ${wallet.id}:`, err);
103-
}
104-
}
110+
allRelevantWallets.push(...relevantWallets);
105111
}
106112

107-
// Filter out weekly allowance cleared transactions only
108-
const allowanceTransactions = allPayments.filter(
109-
f => !f.memo.includes('Weekly Allowance cleared'),
113+
// Fetch all wallet transactions in parallel for better performance
114+
const paymentPromises = allRelevantWallets.map(wallet =>
115+
getWalletTransactionsSince(wallet.inkey, paymentsSinceTimestamp, null)
116+
.catch(err => {
117+
console.error(`Error fetching payments for wallet ${wallet.id}:`, err);
118+
failedWalletCount++;
119+
return [] as Transaction[]; // Return empty array on error
120+
})
110121
);
111122

112-
// Deduplicate internal transfers - only show the incoming side (positive amount)
113-
// For internal transfers, we have 2 records with the same checking_id (one negative, one positive)
114-
// We only want to show one transaction per transfer
115-
const seenCheckingIds = new Set<string>();
116-
const deduplicatedTransactions = allowanceTransactions.filter(payment => {
117-
const cleanId = payment.checking_id?.replace('internal_', '') || '';
118-
119-
// If this is an internal transfer (has matching checking_id)
120-
if (cleanId && payment.checking_id?.startsWith('internal_')) {
121-
// Only show the incoming side (positive amount)
122-
if (payment.amount < 0) {
123-
return false; // Skip outgoing side
124-
}
123+
const paymentResults = await Promise.all(paymentPromises);
124+
const allPayments = paymentResults.flat();
125125

126-
// Check if we've already seen this checking_id
127-
if (seenCheckingIds.has(cleanId)) {
128-
return false; // Skip duplicate
129-
}
130-
seenCheckingIds.add(cleanId);
131-
}
132-
133-
return true;
134-
});
126+
// Log warning if some wallets failed to load
127+
if (failedWalletCount > 0) {
128+
console.warn(`${failedWalletCount} wallet(s) failed to load transactions`);
129+
}
135130

136131
// Create wallet ID to user mapping
137132
const walletToUserMap = new Map<string, User>();
138133
allWalletsData.forEach(userData => {
139-
userData.wallets.forEach(wallet => {
140-
walletToUserMap.set(wallet.id, fetchedUsers.find(u => u.id === userData.userId)!);
141-
});
134+
const user = fetchedUsers.find(u => u.id === userData.userId);
135+
if (user) {
136+
userData.wallets.forEach(wallet => {
137+
walletToUserMap.set(wallet.id, user);
138+
});
139+
} else {
140+
console.warn(`User not found for userId: ${userData.userId} - wallet transactions may show as Unknown`);
141+
}
142142
});
143143

144-
// Create a map of all payments by checking_id for internal transfer matching
144+
// Map payments by checking_id (built before filtering to find receiving side)
145145
const paymentsByCheckingId = new Map<string, Transaction[]>();
146146
allPayments.forEach(payment => {
147147
const cleanId = payment.checking_id?.replace('internal_', '') || '';
@@ -152,46 +152,79 @@ const FeedList: React.FC<FeedListProps> = ({
152152
}
153153
});
154154

155-
const allowanceZaps = deduplicatedTransactions.map((transaction, index) => {
156-
const walletOwner = walletToUserMap.get(transaction.wallet_id) || null;
155+
// Helper to find the receiving payment for a given outgoing payment
156+
const findReceiverWalletId = (payment: Transaction): string | null => {
157+
const cleanId = payment.checking_id?.replace('internal_', '') || '';
158+
if (!cleanId) return null;
159+
160+
const matchingPayments = paymentsByCheckingId.get(cleanId) || [];
161+
const receivingPayment = matchingPayments.find(p =>
162+
p.wallet_id !== payment.wallet_id && p.amount > 0
163+
);
164+
return receivingPayment?.wallet_id || null;
165+
};
166+
167+
// Filter: Only outgoing payments FROM Allowance wallets TO Private wallets
168+
const allowanceTransactions = allPayments.filter(payment => {
169+
// Must be from an Allowance wallet
170+
if (!allowanceWalletIds.has(payment.wallet_id)) return false;
171+
// Must be outgoing (negative amount)
172+
if (payment.amount >= 0) return false;
173+
// Exclude weekly allowance cleared transactions
174+
if (payment.memo?.includes('Weekly Allowance cleared')) return false;
175+
176+
// Verify the receiver is a Private wallet (not external Lightning payment)
177+
const receiverWalletId = findReceiverWalletId(payment);
178+
if (!receiverWalletId || !privateWalletIds.has(receiverWalletId)) {
179+
return false;
180+
}
181+
182+
return true;
183+
});
184+
185+
// Deduplicate internal transfers by checking_id
186+
const seenCheckingIds = new Set<string>();
187+
const deduplicatedTransactions = allowanceTransactions.filter(payment => {
188+
const cleanId = payment.checking_id?.replace('internal_', '') || '';
189+
190+
if (cleanId) {
191+
if (seenCheckingIds.has(cleanId)) {
192+
return false; // Skip duplicate
193+
}
194+
seenCheckingIds.add(cleanId);
195+
}
157196

158-
// Determine if this is incoming (positive amount) or outgoing (negative amount)
159-
const isIncoming = transaction.amount > 0;
197+
return true;
198+
});
199+
200+
const allowanceZaps = deduplicatedTransactions.map((transaction, index) => {
201+
// FROM = owner of the Allowance wallet (sender)
202+
const fromUser = walletToUserMap.get(transaction.wallet_id) || null;
160203

161-
let fromUser: User | null = null;
204+
// TO = recipient (owner of the Private wallet that received the payment)
162205
let toUser: User | null = null;
163206

164-
// Try to find matching internal payment (the other side of the transfer)
207+
// Try to find matching internal payment (the receiving side)
165208
const cleanCheckingId = transaction.checking_id?.replace('internal_', '') || '';
166209
const matchingPayments = paymentsByCheckingId.get(cleanCheckingId) || [];
167210
const matchingPayment = matchingPayments.find(p => p.wallet_id !== transaction.wallet_id);
168211

169-
if (isIncoming) {
170-
// For incoming payments: TO = wallet owner
171-
toUser = walletOwner;
172-
173-
// FROM = the owner of the matching outgoing payment (if found)
174-
if (matchingPayment) {
175-
fromUser = walletToUserMap.get(matchingPayment.wallet_id) || null;
176-
} else {
177-
// Fallback to extra field
178-
const fromUserId = transaction.extra?.from?.user;
179-
fromUser = fromUserId ? fetchedUsers.find(f => f.id === fromUserId) || null : null;
180-
}
181-
} else {
182-
// For outgoing payments: FROM = wallet owner
183-
fromUser = walletOwner;
184-
185-
// TO = the owner of the matching incoming payment (if found)
186-
if (matchingPayment) {
187-
toUser = walletToUserMap.get(matchingPayment.wallet_id) || null;
188-
} else {
189-
// Fallback to extra field
190-
const toUserId = transaction.extra?.to?.user;
191-
toUser = toUserId ? fetchedUsers.find(f => f.id === toUserId) || null : null;
212+
if (matchingPayment) {
213+
toUser = walletToUserMap.get(matchingPayment.wallet_id) || null;
214+
if (!toUser) {
215+
console.warn(`Receiver wallet ${matchingPayment.wallet_id} found but user mapping missing`);
192216
}
193217
}
194218

219+
// Fallback: Try extra.to.user field
220+
if (!toUser && transaction.extra?.to?.user) {
221+
const toUserId = transaction.extra.to.user;
222+
toUser = fetchedUsers.find(f => f.id === toUserId) || null;
223+
}
224+
225+
if (!toUser) {
226+
console.warn(`Could not determine receiver for transaction ${transaction.checking_id}`);
227+
}
195228

196229
return {
197230
from: fromUser,
@@ -223,18 +256,16 @@ const FeedList: React.FC<FeedListProps> = ({
223256
fetchZapsStepByStep();
224257
}
225258
}, [timestamp, adminKey]);
226-
// NEW: Function to handle header clicks for sorting
259+
227260
const handleSort = (field: 'time' | 'from' | 'to' | 'amount') => {
228261
if (sortField === field) {
229-
// Toggle sort order if the same field is clicked
230262
setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc');
231263
} else {
232-
// Change sort field and set default order to ascending
233264
setSortField(field);
234265
setSortOrder('asc');
235266
}
236267
};
237-
// NEW: Sort the zaps array based on the selected sort field and order
268+
238269
const sortedZaps = [...zaps].sort((a, b) => {
239270
let valA, valB;
240271

@@ -266,7 +297,7 @@ const FeedList: React.FC<FeedListProps> = ({
266297
});
267298

268299
// Calculate pagination variables
269-
const totalPages = Math.ceil(sortedZaps.length / ITEMS_PER_PAGE);
300+
const totalPages = Math.max(1, Math.ceil(sortedZaps.length / ITEMS_PER_PAGE));
270301
const indexOfLastItem = currentPage * ITEMS_PER_PAGE;
271302
const indexOfFirstItem = indexOfLastItem - ITEMS_PER_PAGE;
272303
const currentItems = sortedZaps.slice(indexOfFirstItem, indexOfLastItem);

0 commit comments

Comments
 (0)