Skip to content

Commit 0ce2ad1

Browse files
committed
improved security + notes for transfers
1 parent 5100184 commit 0ce2ad1

File tree

10 files changed

+259
-80
lines changed

10 files changed

+259
-80
lines changed

.claude/settings.local.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,10 @@
3939
"Bash(npm ls:*)",
4040
"Bash(npm view:*)",
4141
"Bash(ls /mnt/e/GitHub/rewards-program/src/app/global-error*)",
42-
"Bash(ls /mnt/e/GitHub/rewards-program/src/app/error*)"
42+
"Bash(ls /mnt/e/GitHub/rewards-program/src/app/error*)",
43+
"Read(//mnt/e/GitHub/fula-chain/**)",
44+
"Read(//mnt/e/GitHub/**)",
45+
"Bash(HARDHAT_CONTRACT_SIZER=false npx hardhat test test/governance/integration/RewardsProgram.test.ts --no-compile)"
4346
]
4447
}
4548
}

.env.production

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# Production contract addresses (Base mainnet)
2-
NEXT_PUBLIC_REWARDS_PROGRAM_ADDRESS=0xF028054A9B3ee9d8f2D100CC36C9d3Ff96fd8Dc8
3-
NEXT_PUBLIC_STAKING_POOL_ADDRESS=0x948E96622Dc287fB24d2df693127C58e9410305e
2+
NEXT_PUBLIC_REWARDS_PROGRAM_ADDRESS=0x1e8eC692169C15e4EAA68d2965B670798ffb5fFb
3+
NEXT_PUBLIC_STAKING_POOL_ADDRESS=0xd9016b8C79c54Bec77467614A80A6697BC404B84
44
NEXT_PUBLIC_FULA_TOKEN_ADDRESS=0x9e12735d77c72c5C3670636D428f2F3815d8A4cB
55

66
# WalletConnect

src/app/balance/page.tsx

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -143,8 +143,17 @@ function OwnerActions({ memberWallet, initialProgramId }: { memberWallet: string
143143
// Transfer to Parent
144144
const [parentTo, setParentTo] = useState("");
145145
const [parentAmount, setParentAmount] = useState("");
146+
const [parentNote, setParentNote] = useState("");
146147
const { transferBack, isPending: isTransBack, isConfirming: isTransBackConf, isSuccess: transBackSuccess, error: transBackError } = useTransferToParent();
147148
const { data: transferLimitData } = useTransferLimit(pid);
149+
const { data: myMember } = useReadContract({
150+
address: CONTRACTS.rewardsProgram,
151+
abi: REWARDS_PROGRAM_ABI,
152+
functionName: "getMember",
153+
args: address ? [pid, address] : undefined,
154+
query: { enabled: !!address && pid > 0 },
155+
});
156+
const parentAddr = myMember?.parent && myMember.parent !== zeroAddress ? myMember.parent as string : "";
148157

149158
// Withdraw
150159
const [withdrawAmount, setWithdrawAmount] = useState("");
@@ -209,17 +218,27 @@ function OwnerActions({ memberWallet, initialProgramId }: { memberWallet: string
209218
{/* Transfer to Parent */}
210219
{actionTab === 1 && (
211220
<Box sx={{ pt: 2, maxWidth: 480 }}>
221+
{parentAddr && (
222+
<Alert severity="info" sx={{ mb: 1 }}>
223+
Your parent in this program: <strong>{shortenAddress(parentAddr)}</strong>. Leave wallet empty to transfer directly to them.
224+
</Alert>
225+
)}
212226
{transferLimitData != null && Number(transferLimitData) > 0 && (
213227
<Alert severity="info" sx={{ mb: 1 }}>
214228
Transfer limit: <strong>{Number(transferLimitData)}%</strong> of total balance (Clients only).
215229
</Alert>
216230
)}
217-
<TextField label="Parent Wallet (optional)" value={parentTo} onChange={(e) => setParentTo(e.target.value)}
218-
fullWidth size="small" placeholder="0x... (empty = direct parent)" />
231+
<TextField label="Override Parent Wallet (optional)" value={parentTo} onChange={(e) => setParentTo(e.target.value)}
232+
fullWidth size="small" placeholder="0x..."
233+
helperText="Leave empty to transfer to your direct parent" />
219234
<TextField label="Amount (FULA)" value={parentAmount} onChange={(e) => setParentAmount(e.target.value)}
220235
fullWidth size="small" type="number" sx={{ mt: 1 }} />
236+
<TextField label="Note (optional, max 128)" value={parentNote}
237+
onChange={(e) => setParentNote(e.target.value.slice(0, 128))}
238+
fullWidth size="small" sx={{ mt: 1 }} inputProps={{ maxLength: 128 }}
239+
helperText={`${parentNote.length}/128`} />
221240
<Button size="small" variant="contained" sx={{ mt: 1 }}
222-
onClick={() => transferBack(pid, (parentTo || zeroAddress) as `0x${string}`, parentAmount)}
241+
onClick={() => transferBack(pid, (parentTo || zeroAddress) as `0x${string}`, parentAmount, parentNote)}
223242
disabled={isTransBack || isTransBackConf || !parentAmount || !disclaimer}>
224243
{isTransBack || isTransBackConf ? <CircularProgress size={16} /> : "Transfer"}
225244
</Button>
@@ -328,7 +347,7 @@ function BalanceContent() {
328347
<TextField
329348
label="Member ID (Reward ID)"
330349
value={memberID}
331-
onChange={(e) => setMemberID(e.target.value)}
350+
onChange={(e) => setMemberID(e.target.value.toUpperCase())}
332351
sx={{ flexGrow: 1, minWidth: 150 }}
333352
inputProps={{ maxLength: 12 }}
334353
placeholder="Enter member ID..."

src/app/members/page.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -158,7 +158,7 @@ export default function MembersPage() {
158158
<TextField
159159
label={searchType === "memberID" ? "Member ID (= Reward ID)" : "Program Code"}
160160
value={searchValue}
161-
onChange={(e) => { setSearchValue(searchType === "programCode" ? e.target.value.toUpperCase() : e.target.value); setSearchTriggered(false); }}
161+
onChange={(e) => { setSearchValue(e.target.value.toUpperCase()); setSearchTriggered(false); }}
162162
sx={{ flexGrow: 1, minWidth: 150 }}
163163
inputProps={{ maxLength: searchType === "memberID" ? 12 : 8 }}
164164
/>
@@ -387,7 +387,7 @@ export default function MembersPage() {
387387
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
388388
Current Member ID: <strong>{memberIdStr}</strong> (Program {memberProgramId})
389389
</Typography>
390-
<TextField label="New Member ID" value={newMemberID} onChange={(e) => setNewMemberID(e.target.value)}
390+
<TextField label="New Member ID" value={newMemberID} onChange={(e) => setNewMemberID(e.target.value.toUpperCase())}
391391
fullWidth margin="normal" inputProps={{ maxLength: 12 }} />
392392
{errUpdateID && <Alert severity="error" sx={{ mt: 2 }}>{formatContractError(errUpdateID)}</Alert>}
393393
{successUpdateID && <Alert severity="success" sx={{ mt: 2 }}>Member ID updated!</Alert>}

src/app/programs/page.tsx

Lines changed: 96 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,8 @@ import BlockIcon from "@mui/icons-material/Block";
1515
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
1616
import DeleteIcon from "@mui/icons-material/Delete";
1717
import ContentCopyIcon from "@mui/icons-material/ContentCopy";
18-
import { useAccount } from "wagmi";
18+
import InfoOutlinedIcon from "@mui/icons-material/InfoOutlined";
19+
import { useAccount, useReadContract } from "wagmi";
1920
import { useSearchParams } from "next/navigation";
2021
import { keccak256 } from "viem";
2122
import Link from "next/link";
@@ -30,8 +31,8 @@ import {
3031
useDepositTokens, useTransferToSubMember, useTransferToParent, useWithdraw,
3132
useTokenBalance,
3233
} from "@/hooks/useRewardsProgram";
33-
import { MemberRoleLabels, MemberRoleEnum, MemberTypeLabels } from "@/config/contracts";
34-
import { fromBytes8, fromBytes12, fromBytes16, shortenAddress, formatFula, isValidAddress, formatContractError } from "@/lib/utils";
34+
import { MemberRoleLabels, MemberRoleEnum, MemberTypeLabels, CONTRACTS, REWARDS_PROGRAM_ABI } from "@/config/contracts";
35+
import { fromBytes8, fromBytes12, fromBytes16, toBytes12, shortenAddress, formatFula, isValidAddress, formatContractError } from "@/lib/utils";
3536
import { OnChainDisclaimer } from "@/components/common/OnChainDisclaimer";
3637
import { QRCodeDisplay } from "@/components/common/QRCodeDisplay";
3738

@@ -239,6 +240,16 @@ function ProgramDetail({ programId }: { programId: number }) {
239240
const { data: myBalance } = useMemberBalance(programId, address);
240241
const { data: transferLimit } = useTransferLimit(programId);
241242

243+
// My member info (for parent detection)
244+
const { data: myMember } = useReadContract({
245+
address: CONTRACTS.rewardsProgram,
246+
abi: REWARDS_PROGRAM_ABI,
247+
functionName: "getMember",
248+
args: address ? [programId, address] : undefined,
249+
query: { enabled: !!address && programId > 0 },
250+
});
251+
const parentAddr = myMember?.parent && myMember.parent !== "0x0000000000000000000000000000000000000000" ? myMember.parent as string : "";
252+
242253
const isPA = role === MemberRoleEnum.ProgramAdmin;
243254
const canManageProgram = isAdmin || isPA;
244255
const canManageSubTypes = isAdmin || isPA;
@@ -295,20 +306,42 @@ function ProgramDetail({ programId }: { programId: number }) {
295306
const [depAmount, setDepAmount] = useState("");
296307
const [depNote, setDepNote] = useState("");
297308
const [depDisclaimer, setDepDisclaimer] = useState(false);
309+
const [transMemberCode, setTransMemberCode] = useState("");
298310
const [transTo, setTransTo] = useState("");
299311
const [transAmount, setTransAmount] = useState("");
300312
const [transLocked, setTransLocked] = useState(true);
301313
const [transLockDays, setTransLockDays] = useState("0");
314+
const [transNote, setTransNote] = useState("");
302315
const [transDisclaimer, setTransDisclaimer] = useState(false);
303316
const [parentTo, setParentTo] = useState("");
304317
const [parentAmount, setParentAmount] = useState("");
318+
const [parentNote, setParentNote] = useState("");
305319
const [parentDisclaimer, setParentDisclaimer] = useState(false);
306320
const [withAmount, setWithAmount] = useState("");
307321
const [withDisclaimer, setWithDisclaimer] = useState(false);
308322
const [tokenTab, setTokenTab] = useState(0);
309323
const canTransferSub = isAdmin || isPA || role === MemberRoleEnum.TeamLeader;
310324
const isMember = role > 0 || isAdmin;
311325

326+
// Resolve member code → storage key for transfers
327+
const transMemberCodeBytes = transMemberCode.length > 0 ? toBytes12(transMemberCode) : undefined;
328+
const { data: transResolvedKey } = useReadContract({
329+
address: CONTRACTS.rewardsProgram,
330+
abi: REWARDS_PROGRAM_ABI,
331+
functionName: "memberIDLookup",
332+
args: transMemberCodeBytes ? [transMemberCodeBytes, programId] : undefined,
333+
query: { enabled: !!transMemberCodeBytes && programId > 0 },
334+
});
335+
const { data: transResolvedMember } = useReadContract({
336+
address: CONTRACTS.rewardsProgram,
337+
abi: REWARDS_PROGRAM_ABI,
338+
functionName: "getMemberByID",
339+
args: transMemberCodeBytes ? [transMemberCodeBytes, programId] : undefined,
340+
query: { enabled: !!transMemberCodeBytes && programId > 0 },
341+
});
342+
const transResolvedAddr = transResolvedKey && transResolvedKey !== "0x0000000000000000000000000000000000000000" ? transResolvedKey as string : "";
343+
const transTarget = transResolvedAddr || transTo;
344+
312345
const paWalletValid = !paWallet || isValidAddress(paWallet);
313346
const mWalletValid = !mWallet || isValidAddress(mWallet);
314347

@@ -529,27 +562,57 @@ function ProgramDetail({ programId }: { programId: number }) {
529562
{/* Transfer to Sub-Member */}
530563
{canTransferSub && tokenTab === 1 && (
531564
<Box sx={{ pt: 2, maxWidth: 480 }}>
532-
<TextField label="Recipient Wallet" value={transTo} onChange={(e) => setTransTo(e.target.value)}
533-
fullWidth size="small" error={!!transTo && !isValidAddress(transTo)} />
565+
<TextField label="Recipient Member Code" value={transMemberCode}
566+
onChange={(e) => setTransMemberCode(e.target.value.toUpperCase().slice(0, 12))}
567+
fullWidth size="small" placeholder="e.g. ALICE01"
568+
inputProps={{ maxLength: 12 }} />
569+
{transMemberCode && transResolvedAddr && transResolvedMember && (
570+
<Alert severity="success" sx={{ mt: 1 }}>
571+
Resolved: <strong>{fromBytes12(transResolvedMember.memberID)}</strong>{MemberRoleLabels[transResolvedMember.role] || "Unknown"}
572+
{transResolvedMember.wallet && transResolvedMember.wallet !== "0x0000000000000000000000000000000000000000"
573+
? ` (${shortenAddress(transResolvedMember.wallet)})`
574+
: " (walletless member)"}
575+
</Alert>
576+
)}
577+
{transMemberCode && !transResolvedAddr && transMemberCodeBytes && (
578+
<Alert severity="warning" sx={{ mt: 1 }}>Member not found in this program.</Alert>
579+
)}
580+
<TextField label="Override Wallet (optional)" value={transTo} onChange={(e) => setTransTo(e.target.value)}
581+
fullWidth size="small" sx={{ mt: 1 }} placeholder="0x... (only if member code is empty)"
582+
error={!!transTo && !isValidAddress(transTo)}
583+
helperText="Used only when member code is empty"
584+
disabled={!!transResolvedAddr} />
534585
<TextField label="Amount (FULA)" value={transAmount} onChange={(e) => setTransAmount(e.target.value)}
535586
fullWidth size="small" type="number" sx={{ mt: 1 }} />
536-
<FormControl fullWidth size="small" sx={{ mt: 1 }}>
537-
<InputLabel>Lock</InputLabel>
538-
<Select value={transLocked ? "locked" : "unlocked"}
539-
onChange={(e) => setTransLocked(e.target.value === "locked")} label="Lock">
540-
<MenuItem value="locked">Permanently Locked</MenuItem>
541-
<MenuItem value="unlocked">Unlocked / Time-locked</MenuItem>
542-
</Select>
543-
</FormControl>
544-
{!transLocked && (
587+
<Box sx={{ display: "flex", alignItems: "center", gap: 0.5, mt: 1 }}>
588+
<FormControl fullWidth size="small">
589+
<InputLabel>Lock</InputLabel>
590+
<Select value={transLocked ? "locked" : "unlocked"}
591+
onChange={(e) => { setTransLocked(e.target.value === "locked"); if (e.target.value === "locked") setTransLockDays("0"); }} label="Lock">
592+
<MenuItem value="locked">Permanently Locked</MenuItem>
593+
<MenuItem value="unlocked">Unlocked / Time-locked</MenuItem>
594+
</Select>
595+
</FormControl>
596+
<Tooltip title="If you check Permanently Locked, the recipient can only transfer the tokens back to sender and cannot withdraw to their wallet" arrow>
597+
<InfoOutlinedIcon sx={{ fontSize: 16, color: "text.secondary", cursor: "help" }} />
598+
</Tooltip>
599+
</Box>
600+
<Box sx={{ display: "flex", alignItems: "center", gap: 0.5, mt: 1 }}>
545601
<TextField label="Lock Days (0 = unlocked)" value={transLockDays}
546602
onChange={(e) => setTransLockDays(e.target.value)}
547-
fullWidth size="small" type="number" sx={{ mt: 1 }} />
548-
)}
603+
fullWidth size="small" type="number" disabled={transLocked} />
604+
<Tooltip title="If you set a time, the user needs to wait for that number of days before they can withdraw tokens to their wallet. They can still transfer tokens back to sender at any time without waiting" arrow>
605+
<InfoOutlinedIcon sx={{ fontSize: 16, color: "text.secondary", cursor: "help" }} />
606+
</Tooltip>
607+
</Box>
608+
<TextField label="Note (optional, max 128)" value={transNote}
609+
onChange={(e) => setTransNote(e.target.value.slice(0, 128))}
610+
fullWidth size="small" sx={{ mt: 1 }} inputProps={{ maxLength: 128 }}
611+
helperText={`${transNote.length}/128`} />
549612
<OnChainDisclaimer accepted={transDisclaimer} onChange={setTransDisclaimer} />
550613
<Button variant="contained" fullWidth sx={{ mt: 1 }}
551-
onClick={() => transfer(programId, transTo as `0x${string}`, transAmount, transLocked, parseInt(transLockDays) || 0)}
552-
disabled={isTransPending || isTransConf || !transTo || !transAmount || !isValidAddress(transTo) || !transDisclaimer}>
614+
onClick={() => transfer(programId, transTarget as `0x${string}`, transAmount, transLocked, parseInt(transLockDays) || 0, transNote)}
615+
disabled={isTransPending || isTransConf || !transTarget || !transAmount || (!transResolvedAddr && !!transTo && !isValidAddress(transTo)) || !transDisclaimer}>
553616
{isTransPending || isTransConf ? <CircularProgress size={16} /> : "Transfer"}
554617
</Button>
555618
{transSuccess && <Alert severity="success" sx={{ mt: 1 }}>Transferred!</Alert>}
@@ -560,17 +623,27 @@ function ProgramDetail({ programId }: { programId: number }) {
560623
{/* Transfer to Parent */}
561624
{tokenTab === (canTransferSub ? 2 : 1) && (
562625
<Box sx={{ pt: 2, maxWidth: 480 }}>
626+
{parentAddr && (
627+
<Alert severity="info" sx={{ mb: 1 }}>
628+
Your parent: <strong>{shortenAddress(parentAddr)}</strong>. Leave wallet empty to transfer directly to them.
629+
</Alert>
630+
)}
563631
{transferLimit != null && Number(transferLimit) > 0 && (
564632
<Alert severity="info" sx={{ mb: 1 }}>Limit: {String(transferLimit)}% of total balance</Alert>
565633
)}
566-
<TextField label="Parent Wallet (empty = direct parent)" value={parentTo}
634+
<TextField label="Override Parent Wallet (optional)" value={parentTo}
567635
onChange={(e) => setParentTo(e.target.value)}
568-
fullWidth size="small" error={!!parentTo && !isValidAddress(parentTo)} />
636+
fullWidth size="small" error={!!parentTo && !isValidAddress(parentTo)}
637+
helperText="Leave empty to transfer to your direct parent" />
569638
<TextField label="Amount (FULA)" value={parentAmount} onChange={(e) => setParentAmount(e.target.value)}
570639
fullWidth size="small" type="number" sx={{ mt: 1 }} />
640+
<TextField label="Note (optional, max 128)" value={parentNote}
641+
onChange={(e) => setParentNote(e.target.value.slice(0, 128))}
642+
fullWidth size="small" sx={{ mt: 1 }} inputProps={{ maxLength: 128 }}
643+
helperText={`${parentNote.length}/128`} />
571644
<OnChainDisclaimer accepted={parentDisclaimer} onChange={setParentDisclaimer} />
572645
<Button variant="contained" fullWidth sx={{ mt: 1 }}
573-
onClick={() => transferBack(programId, (parentTo || "0x0000000000000000000000000000000000000000") as `0x${string}`, parentAmount)}
646+
onClick={() => transferBack(programId, (parentTo || "0x0000000000000000000000000000000000000000") as `0x${string}`, parentAmount, parentNote)}
574647
disabled={isTransBackPending || isTransBackConf || !parentAmount || !parentDisclaimer}>
575648
{isTransBackPending || isTransBackConf ? <CircularProgress size={16} /> : "Transfer to Parent"}
576649
</Button>
@@ -623,7 +696,7 @@ function ProgramDetail({ programId }: { programId: number }) {
623696
fullWidth margin="normal" placeholder="0x..."
624697
error={!!paWallet && !paWalletValid}
625698
helperText={paWallet && !paWalletValid ? "Invalid wallet address" : !paWallet ? "Walletless: an edit code will be generated for claiming" : ""} />
626-
<TextField label="Member ID" value={paMemberId} onChange={(e) => setPaMemberId(e.target.value)}
699+
<TextField label="Member ID" value={paMemberId} onChange={(e) => setPaMemberId(e.target.value.toUpperCase())}
627700
fullWidth margin="normal" inputProps={{ maxLength: 12 }} />
628701
<FormControl fullWidth margin="normal">
629702
<InputLabel>Member Type</InputLabel>
@@ -660,7 +733,7 @@ function ProgramDetail({ programId }: { programId: number }) {
660733
fullWidth margin="normal" placeholder="0x..."
661734
error={!!mWallet && !mWalletValid}
662735
helperText={mWallet && !mWalletValid ? "Invalid wallet address" : !mWallet ? "Walletless: an edit code will be generated for claiming" : ""} />
663-
<TextField label="Member ID" value={mMemberId} onChange={(e) => setMMemberId(e.target.value)}
736+
<TextField label="Member ID" value={mMemberId} onChange={(e) => setMMemberId(e.target.value.toUpperCase())}
664737
fullWidth margin="normal" inputProps={{ maxLength: 12 }} />
665738
<FormControl fullWidth margin="normal">
666739
<InputLabel>Role</InputLabel>

0 commit comments

Comments
 (0)