diff --git a/README.md b/README.md index d9c9a8b..e4b2a67 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ As a result, developers are able to unfairly underpay whitehats, or even refuse To solve this issue, we have developed Bug Buster—a trustless bug bounty platform powered by [Cartesi Rollups](https://www.cartesi.io/). Running inside a deterministic RISC-V machine that boots Linux, Bug Buster accepts applications written in any major programming language[^1]. -Through a friendly web interface, anyone can submit applications, and sponsor them with Ether to incentivize hackers! All major wallets are supported[^2]. +Through a friendly web interface, anyone can submit applications, and sponsor them with ERC-20 tokens to incentivize hackers! All major wallets are supported[^2]. Meanwhile, hackers can test their exploits right on the browser, without even having to sign Web3 transactions! Once the hacker finds a valid exploit, they can finally send a transaction requesting the reward to be transferred to their account. If, however, no one is able to submit a valid exploit until a certain deadline, the sponsors may request a refund. diff --git a/cli/cmd/send.go b/cli/cmd/send.go index 4e72b24..1dae0b5 100644 --- a/cli/cmd/send.go +++ b/cli/cmd/send.go @@ -9,6 +9,7 @@ import ( "os/exec" "time" + "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/common/hexutil" "github.com/rollmelette/rollmelette" "github.com/spf13/cobra" @@ -52,15 +53,14 @@ func sendDo(inputKind shared.InputKind, payload any, send func(string, context.C log.Printf("input added\n%s", output) } -func sendEther(txValue *big.Int, inputKind shared.InputKind, payload any) { +func sendERC20(token common.Address, value *big.Int, inputKind shared.InputKind, payload any) { sendDo(inputKind, payload, func(inputJsonStr string, ctx context.Context) ([]byte, error) { cmd := exec.CommandContext(ctx, "cast", "send", "--unlocked", "--from", sendArgs.fromAddress, - "--value", txValue.String(), - addressBook.EtherPortal.String(), // TO - "depositEther(address,bytes)", // SIG - dappAddress, inputJsonStr, // ARGS + addressBook.ERC20Portal.String(), // TO + "depositERC20Tokens(address,address,uint256,bytes)", // SIG + token.String(), dappAddress, value.String(), inputJsonStr, // ARGS ) return cmd.CombinedOutput() }) diff --git a/cli/cmd/sponsor.go b/cli/cmd/sponsor.go index 254c3d9..a1d6d94 100644 --- a/cli/cmd/sponsor.go +++ b/cli/cmd/sponsor.go @@ -5,6 +5,7 @@ import ( "log" "math/big" + "github.com/ethereum/go-ethereum/common" "github.com/spf13/cobra" ) @@ -18,24 +19,23 @@ var ( sponsorBountyIndex int sponsorName string sponsorImgLink string + sponsorToken string sponsorValue string ) func sponsorRun(cmd *cobra.Command, args []string) { - etherValue, ok := new(big.Float).SetString(sponsorValue) + value, ok := new(big.Int).SetString(sponsorValue, 10) if !ok { log.Fatalf("failed to parse value") + return } - tenToEighteen := new(big.Float).SetFloat64(1e18) - weiValue := new(big.Float).Mul(etherValue, tenToEighteen) - value := new(big.Int) - weiValue.Int(value) + token := common.HexToAddress(sponsorToken) payload := &shared.AddSponsorship{ BountyIndex: sponsorBountyIndex, Name: sponsorName, ImgLink: sponsorImgLink, } - sendEther(value, shared.AddSponsorshipInputKind, payload) + sendERC20(token, value, shared.AddSponsorshipInputKind, payload) } func init() { @@ -53,6 +53,10 @@ func init() { &sponsorImgLink, "image", "i", "", "Sponsor image") sponsorCmd.Flags().StringVarP( - &sponsorValue, "value", "v", "", "Value to sponsor in Ether") + &sponsorToken, "token", "t", "", "Address of ERC-20 token") + sponsorCmd.MarkFlagRequired("token") + + sponsorCmd.Flags().StringVarP( + &sponsorValue, "value", "v", "", "Amount of tokens to sponsor") sponsorCmd.MarkFlagRequired("value") } diff --git a/contract/main.go b/contract/main.go index e8d0f5c..388bfbc 100644 --- a/contract/main.go +++ b/contract/main.go @@ -73,6 +73,7 @@ func (c *BugBusterContract) Advance( ImgLink: inputPayload.ImgLink, Description: inputPayload.Description, Deadline: inputPayload.Deadline, + Token: inputPayload.Token, Sponsorships: nil, Exploit: nil, Withdrawn: false, @@ -108,21 +109,24 @@ func (c *BugBusterContract) Advance( return fmt.Errorf("can't add sponsorship after deadline") } - var etherDepositSender common.Address - var etherDepositValue *uint256.Int + var erc20DepositSender common.Address + var erc20DepositValue *uint256.Int switch deposit := deposit.(type) { - case *rollmelette.EtherDeposit: - etherDepositSender = deposit.Sender - etherDepositValue, _ = uint256.FromBig(deposit.Value) + case *rollmelette.ERC20Deposit: + if deposit.Token != bounty.Token { + return fmt.Errorf("wrong token: %v", bounty.Token) + } + erc20DepositSender = deposit.Sender + erc20DepositValue, _ = uint256.FromBig(deposit.Amount) default: return fmt.Errorf("unsupported deposit: %T", deposit) } - sponsorship := bounty.GetSponsorship(etherDepositSender) + sponsorship := bounty.GetSponsorship(erc20DepositSender) if sponsorship != nil { // Add to existing sponsorship - newValue := new(uint256.Int).Add(sponsorship.Value, etherDepositValue) + newValue := new(uint256.Int).Add(sponsorship.Value, erc20DepositValue) sponsorship.Value = newValue // Update profile sponsorship.Sponsor.Name = inputPayload.Name @@ -131,11 +135,11 @@ func (c *BugBusterContract) Advance( // Create new sponsorship sponsorship := &shared.Sponsorship{ Sponsor: shared.Profile{ - Address: etherDepositSender, + Address: erc20DepositSender, Name: inputPayload.Name, ImgLink: inputPayload.ImgLink, }, - Value: etherDepositValue, + Value: erc20DepositValue, } bounty.Sponsorships = append(bounty.Sponsorships, sponsorship) } @@ -171,7 +175,7 @@ func (c *BugBusterContract) Advance( // generate voucher for each sponsor for _, sponsorship := range bounty.Sponsorships { - _, err := env.EtherWithdraw(sponsorship.Sponsor.Address, sponsorship.Value.ToBig()) + _, err := env.ERC20Withdraw(bounty.Token, sponsorship.Sponsor.Address, sponsorship.Value.ToBig()) if err != nil { return fmt.Errorf("failed to withdraw: %v", err) } @@ -224,7 +228,7 @@ func (c *BugBusterContract) Advance( if sponsor == hacker { continue } - err := env.EtherTransfer(sponsor, hacker, sponsorship.Value.ToBig()) + err := env.ERC20Transfer(bounty.Token, sponsor, hacker, sponsorship.Value.ToBig()) if err != nil { // this should be impossible return fmt.Errorf("failed to transfer asset: %v", err) @@ -232,7 +236,7 @@ func (c *BugBusterContract) Advance( } // generate voucher - _, err := env.EtherWithdraw(hacker, accBounty.ToBig()) + _, err := env.ERC20Withdraw(bounty.Token, hacker, accBounty.ToBig()) if err != nil { return fmt.Errorf("failed to generate voucher: %v", err) } diff --git a/frontend/src/app/bounty/[bountyId]/exploit/page.tsx b/frontend/src/app/bounty/[bountyId]/exploit/page.tsx index 1a8323a..03e6e10 100644 --- a/frontend/src/app/bounty/[bountyId]/exploit/page.tsx +++ b/frontend/src/app/bounty/[bountyId]/exploit/page.tsx @@ -19,6 +19,7 @@ import { import { isNotEmpty, useForm } from "@mantine/form"; import { useWaitForTransaction } from "wagmi"; + import { TestExploit } from "../../../../model/inputs"; import { usePrepareSendExploit } from "../../../../hooks/bug-buster"; import { useInputBoxAddInput } from "../../../../hooks/contracts"; @@ -26,6 +27,7 @@ import { useInputBoxAddInput } from "../../../../hooks/contracts"; import { BountyParams, ConcreteBountyParams } from "../utils.tsx"; import { useBounty } from "../../../../model/reader"; import { FileDrop } from "../../../../components/filedrop"; +import { transactionStatus } from "../../../../utils/transactionStatus"; interface FileDropTextParams { filename?: string; @@ -53,21 +55,32 @@ interface SendExploitFormValues { const SendExploitForm: FC = ({ bountyIndex, bounty }) => { const [filename, setFilename] = useState(); - const form = useForm({ - initialValues: { - name: "", - exploit: "", + const initialValues: SendExploitFormValues = { + name: "", + exploit: "", + }; + + const form = useForm({ + initialValues, + transformValues: (values) => { + return { + ...values, + exploit: btoa(values.exploit), + }; }, + validateInputOnBlur: true, + validateInputOnChange: true, validate: { name: isNotEmpty("An exploiter name is required"), }, }); - const { name, imgLink, exploit } = form.values; + const { name, imgLink, exploit } = form.getTransformedValues(); const [exploitOutput, setExploitOutput] = useState(""); + const [exploitLoading, setExploitLoading] = useState(false); - async function testExploitAsync(bountyIndex: number, exploit: string) { + async function testExploitAsyncAux(bountyIndex: number, exploit: string) { const inspectRequest: TestExploit = { bountyIndex, exploit }; setExploitOutput("Testing..."); @@ -96,24 +109,40 @@ const SendExploitForm: FC = ({ bountyIndex, bounty }) => { } } + async function testExploitAsync(bountyIndex: number, exploit: string) { + setExploitLoading(true); + try { + await testExploitAsyncAux(bountyIndex, exploit); + } catch (e) { + if (e instanceof Error) { + setExploitOutput(e.message); + } + } + setExploitLoading(false); + } + const testExploit = () => { testExploitAsync(bountyIndex, exploit); }; - const config = usePrepareSendExploit({ + const addInputPrepare = usePrepareSendExploit({ name, imgLink, bountyIndex, exploit, }); - const { data, write } = useInputBoxAddInput(config); - const { isLoading, isSuccess } = useWaitForTransaction({ - hash: data?.hash, + const addInputWrite = useInputBoxAddInput(addInputPrepare.config); + + const addInputWait = useWaitForTransaction({ + hash: addInputWrite.data?.hash, }); + const { disabled: addInputDisabled, loading: addInputLoading } = + transactionStatus(addInputPrepare, addInputWrite, addInputWait); + return ( -
write && write())}> + Submit exploit @@ -147,13 +176,7 @@ const SendExploitForm: FC = ({ bountyIndex, bounty }) => { input: { fontFamily: "monospace" }, }} placeholder="Exploit code" - error={form.getInputProps("exploit").error} - onChange={(e) => - form.setFieldValue( - "exploit", - btoa(e.target.value), - ) - } + {...form.getInputProps("exploit")} /> @@ -170,7 +193,7 @@ const SendExploitForm: FC = ({ bountyIndex, bounty }) => { .map((b) => String.fromCharCode(b)) .join(""); - form.setFieldValue("exploit", btoa(str)); + form.setFieldValue("exploit", str); setFilename(fileWithPath.name); }); } @@ -207,19 +230,22 @@ const SendExploitForm: FC = ({ bountyIndex, bounty }) => { )} - diff --git a/frontend/src/app/bounty/[bountyId]/page.tsx b/frontend/src/app/bounty/[bountyId]/page.tsx index 5dc8fbb..4b34652 100644 --- a/frontend/src/app/bounty/[bountyId]/page.tsx +++ b/frontend/src/app/bounty/[bountyId]/page.tsx @@ -12,7 +12,7 @@ import { Text, } from "@mantine/core"; -import { formatEther } from "viem"; +import { useWaitForTransaction } from "wagmi"; import { parseHexAsJson, @@ -25,69 +25,80 @@ import { SendExploit } from "../../../model/inputs"; import { usePrepareWithdrawSponsorship } from "../../../hooks/bug-buster"; import { useInputBoxAddInput } from "../../../hooks/contracts"; -import { BountyParams } from "./utils"; +import { BountyParams, ConcreteBountyParams } from "./utils"; import { useBlockTimestamp } from "../../../hooks/block"; import { BountyStatus } from "../../../model/bountyStatus"; import { getBountyStatus } from "../../../utils/bounty"; +import { useErc20Metadata, formatErc20Amount } from "../../../utils/erc20"; import { BountyStatusBadgeGroup } from "../../../components/bountyStatus"; -import { useWaitForTransaction } from "wagmi"; import { ProfileCard } from "../../../components/profileCard"; import { LinkButton } from "../../../components/linkbtn"; import { HasConnectedAccount } from "../../../components/hasConnectedAccount"; +import { transactionStatus } from "../../../utils/transactionStatus"; const WithdrawButton: FC<{ - bountyId: string; - disabled: boolean; -}> = ({ bountyId, disabled }) => { - const bountyIndex = Number(bountyId); - const config = usePrepareWithdrawSponsorship({ bountyIndex }); - const { data, write } = useInputBoxAddInput(config); - const { isLoading, isSuccess } = useWaitForTransaction({ - hash: data?.hash, + bountyIndex: number; + canWithdraw: boolean; +}> = ({ bountyIndex, canWithdraw }) => { + const addInputPrepare = usePrepareWithdrawSponsorship({ bountyIndex }); + + const addInputWrite = useInputBoxAddInput(addInputPrepare.config); + + const addInputWait = useWaitForTransaction({ + hash: addInputWrite.data?.hash, }); + + const { disabled: addInputDisabled, loading: addInputLoading } = + transactionStatus(addInputPrepare, addInputWrite, addInputWait); + return ( - - - - {isSuccess && ( - <> - Withdraw transaction successful! - - )} - - + ); }; const ButtonsBox: FC<{ - bountyId: string; + bountyIndex: number; bountyStatus: BountyStatus; -}> = ({ bountyId, bountyStatus }) => { +}> = ({ bountyIndex, bountyStatus }) => { const isOpen = bountyStatus.kind == "open"; - const enableWithdrawals = - bountyStatus.kind == "expired" && !bountyStatus.withdrawn; + const canWithdraw = + bountyStatus.kind === "expired" && !bountyStatus.withdrawn; return ( - + Sponsor - + Submit exploit - + ); }; -const BountyBox: FC<{ - bountyId: string; - bounty: AppBounty; -}> = ({ bountyId, bounty }) => { +const BountyBox: FC = ({ bountyIndex, bounty }) => { const blockTimestamp = useBlockTimestamp(); const bountyStatus = getBountyStatus(bounty, blockTimestamp); const totalPrize = getBountyTotalPrize(bounty); + const { token } = bounty; + const erc20Metadata = useErc20Metadata(token); return ( @@ -103,9 +114,15 @@ const BountyBox: FC<{ {bounty.description} - Total Prize: {formatEther(totalPrize)} ETH + + Total Prize:{" "} + {formatErc20Amount(token, totalPrize, erc20Metadata)} + - + ); @@ -126,6 +143,24 @@ const ExploitCodeBox: FC<{ exploitCode?: string }> = ({ exploitCode }) => { const ParticipantsBox: FC<{ bounty: AppBounty; }> = ({ bounty }) => { + const { token } = bounty; + const erc20Metadata = useErc20Metadata(token); + + const sponsorships = bounty.sponsorships?.map( + ({ sponsor, value: amount }, index) => { + return ( + + {formatErc20Amount(token, BigInt(amount), erc20Metadata)} + + ); + }, + ); + return ( Participants @@ -135,19 +170,7 @@ const ParticipantsBox: FC<{ badge="Exploiter" /> )} - {bounty.sponsorships && - bounty.sponsorships.map((sponsorship, index) => { - return ( - - {formatEther(BigInt(sponsorship.value))} ETH - - ); - })} + {sponsorships} ); }; @@ -208,7 +231,7 @@ const BountyInfoPage: FC = ({ params: { bountyId } }) => { return (
- + diff --git a/frontend/src/app/bounty/[bountyId]/sponsor/page.tsx b/frontend/src/app/bounty/[bountyId]/sponsor/page.tsx index 8a79975..0c7d7be 100644 --- a/frontend/src/app/bounty/[bountyId]/sponsor/page.tsx +++ b/frontend/src/app/bounty/[bountyId]/sponsor/page.tsx @@ -3,53 +3,95 @@ import { Box, Button, Center, - NumberInput, + Group, Stack, + Text, TextInput, - useMantineTheme, Title, - Text, + useMantineTheme, } from "@mantine/core"; import { isNotEmpty, useForm } from "@mantine/form"; -import { FC } from "react"; -import { parseEther } from "viem"; -import { AddSponsorship } from "../../../../model/inputs"; -import { usePrepareAddSponsorship } from "../../../../hooks/bug-buster"; -import { useEtherPortalDepositEther } from "../../../../hooks/contracts"; -import { useWaitForTransaction } from "wagmi"; +import { FC, useEffect } from "react"; +import { useWaitForTransaction, useAccount } from "wagmi"; +import { parseUnits } from "viem"; import { BountyParams, ConcreteBountyParams } from "../utils.tsx"; +import { usePrepareAddSponsorship } from "../../../../hooks/bug-buster"; +import { + erc20PortalAddress, + useErc20PortalDepositErc20Tokens, + usePrepareErc20Approve, + useErc20Approve, +} from "../../../../hooks/contracts"; +import { AddSponsorship } from "../../../../model/inputs"; import { useBounty } from "../../../../model/reader"; - -const toWei = (input: string | number) => { - if (typeof input == "number") { - return BigInt(input * 1e18); - } else { - return parseEther(input); - } -}; - -interface AddSponsorshipFormValues { - name: string; - imgLink?: string; - value: number | string; -} +import { isPositiveNumber } from "../../../../utils/form"; +import { transactionStatus } from "../../../../utils/transactionStatus"; +import { useErc20Metadata, useErc20UserData } from "../../../../utils/erc20"; const AddSponsorshipForm: FC = ({ bountyIndex, bounty, }) => { - const form = useForm({ + const { token } = bounty; + + const { address: sponsorAddress } = useAccount(); + + const { decimals, symbol } = useErc20Metadata(token); + + const { balance, allowance } = useErc20UserData(token, { + user: sponsorAddress, + spender: erc20PortalAddress, + watch: true, + }); + + const form = useForm({ + validateInputOnChange: true, initialValues: { name: "", - value: 0, + imgLink: "", + amount: "", }, validate: { name: isNotEmpty("A sponsor name is required"), + amount: isPositiveNumber("A valid amount is required"), }, + transformValues: (values) => ({ + name: values.name, + imgLink: values.imgLink !== "" ? values.imgLink : undefined, + amount: + values.amount !== "" && decimals !== undefined + ? parseUnits(values.amount, decimals) + : undefined, + }), }); - const { name, imgLink, value } = form.values; + const { name, imgLink, amount } = form.getTransformedValues(); + + // Approve + + const approvePrepare = usePrepareErc20Approve({ + address: token, + args: [erc20PortalAddress, amount ?? 0n], + enabled: amount !== undefined, + }); + + const approveWrite = useErc20Approve(approvePrepare.config); + + const approveWait = useWaitForTransaction({ + hash: approveWrite.data?.hash, + }); + + const { disabled: approveDisabled, loading: approveLoading } = + transactionStatus(approvePrepare, approveWrite, approveWait); + + const needApproval = + allowance !== undefined && + decimals !== undefined && + amount !== undefined && + allowance < amount; + + // Deposit const addSponsorship: AddSponsorship = { name, @@ -57,15 +99,42 @@ const AddSponsorshipForm: FC = ({ bountyIndex, }; - const config = usePrepareAddSponsorship(addSponsorship, toWei(value)); + const depositPrepare = usePrepareAddSponsorship( + addSponsorship, + token, + amount ?? 0n, + ); + + const depositWrite = useErc20PortalDepositErc20Tokens( + depositPrepare.config, + ); - const { data, write } = useEtherPortalDepositEther(config); - const { isLoading, isSuccess } = useWaitForTransaction({ - hash: data?.hash, + const depositWait = useWaitForTransaction({ + hash: depositWrite.data?.hash, }); + const { disabled: depositDisabled, loading: depositLoading } = + transactionStatus(depositPrepare, depositWrite, depositWait); + + const canDeposit = + allowance !== undefined && + balance !== undefined && + decimals !== undefined && + amount !== undefined && + amount > 0 && + amount <= allowance && + amount <= balance; + + const { refetch } = depositPrepare; + + // May need to refetch deposit configuration if allowance or balance change, + // because they may influence the outcome of the function call. + useEffect(() => { + refetch(); + }, [balance, allowance, refetch]); + return ( - write && write())}> + Sponsor bounty @@ -84,22 +153,48 @@ const AddSponsorshipForm: FC = ({ placeholder="https://" {...form.getInputProps("imgLink")} /> - - + type="number" + min={0} + step={1} + label="Amount" + rightSection={symbol ? {symbol} : undefined} + rightSectionWidth={60} + placeholder="0" + {...form.getInputProps("amount")} + /> + + + + ); diff --git a/frontend/src/app/bounty/create/page.tsx b/frontend/src/app/bounty/create/page.tsx index 818a123..5c27043 100644 --- a/frontend/src/app/bounty/create/page.tsx +++ b/frontend/src/app/bounty/create/page.tsx @@ -2,35 +2,34 @@ import { FC, useState, useEffect } from "react"; import { - Anchor, Box, Button, Center, Stack, - Tabs, Title, TextInput, Textarea, useMantineTheme, - Text, } from "@mantine/core"; import { DateInput } from "@mantine/dates"; import { isNotEmpty, useForm } from "@mantine/form"; +import { isAddress, zeroAddress } from "viem"; import { useInputBoxAddInput } from "../../../hooks/contracts"; import { useWaitForTransaction } from "wagmi"; import { CreateAppBounty } from "../../../model/inputs"; import { usePrepareCreateBounty } from "../../../hooks/bug-buster"; import { useBlockTimestamp } from "../../../hooks/block"; -import { DISCORD_CHANNEL_URL, X_ACCOUNT_URL } from "../../../utils/links"; +import { transactionStatus } from "../../../utils/transactionStatus"; interface CreateBountyFormValues { - name: string; - description: string; + name?: string; + description?: string; imgLink?: string; deadline?: Date; codeZipBinary?: string; codeZipPath?: string; + token?: string; } const CreateBountyForm: FC = () => { @@ -47,39 +46,71 @@ const CreateBountyForm: FC = () => { } }, [blockTimestamp]); - const form = useForm({ - initialValues: { - name: "", - description: "", + const form = useForm({ + initialValues: {} as CreateBountyFormValues, + transformValues: (values) => { + const { deadline, token } = values; + return { + ...values, + token: + token !== undefined && isAddress(token) ? token : undefined, + deadline: + deadline !== undefined + ? deadline.getTime() / 1000 + : undefined, + }; }, + validateInputOnChange: true, + validateInputOnBlur: true, validate: { name: isNotEmpty("A name is required"), description: isNotEmpty("A description is required"), deadline: isNotEmpty("A deadline is required"), codeZipPath: isNotEmpty("A code path is required"), + token: (token) => { + if (token === undefined) { + return "A token address is required"; + } else { + if (isAddress(token)) { + return null; + } else { + return "Invalid token address"; + } + } + }, }, }); + const { name, description, deadline, token } = form.getTransformedValues(); + const bounty: CreateAppBounty = { ...form.values, - deadline: (form.values.deadline ?? new Date()).getTime() / 1000, + name: name ?? "", + description: description ?? "", + deadline: deadline ?? 0, + token: token ?? zeroAddress, }; - const config = usePrepareCreateBounty(bounty); + const addInputPrepare = usePrepareCreateBounty(bounty); + + const addInputWrite = useInputBoxAddInput(addInputPrepare.config); - const { data, write } = useInputBoxAddInput(config); - const { isLoading, isSuccess } = useWaitForTransaction({ - hash: data?.hash, + const addInputWait = useWaitForTransaction({ + hash: addInputWrite.data?.hash, }); + const { disabled: addInputDisabled, loading: addInputLoading } = + transactionStatus(addInputPrepare, addInputWrite, addInputWait); + return ( -
write && write())}> + Create bounty @@ -87,58 +118,50 @@ const CreateBountyForm: FC = () => { withAsterisk size="lg" label="Description" - placeholder="Describe the application, exploit format, assertion script, etc" + description="Describe the application, exploit format, assertion script, etc" {...form.getInputProps("description")} /> + - - - Built-in - Upload - - - - Due to base layer constraints, only built-in - bounties are well supported. -
- If you would like to have your bounty available on - Bug Buster, please send us a message on our{" "} - - Discord channel - {" "} - or to our{" "} - - X account - - . -
-
- - - -
+
diff --git a/frontend/src/app/voucher/page.tsx b/frontend/src/app/voucher/page.tsx index 0bb89a0..0cbb97e 100644 --- a/frontend/src/app/voucher/page.tsx +++ b/frontend/src/app/voucher/page.tsx @@ -11,13 +11,22 @@ import { Stack, Text, } from "@mantine/core"; +import { Address, isAddressEqual } from "viem"; +import { + useAccount, + useContractRead, + usePrepareContractWrite, + useContractWrite, + useWaitForTransaction, +} from "wagmi"; + import { useVouchers } from "../../model/reader"; import { Voucher } from "../../utils/voucher"; -import { decodeVoucher, filterVouchersByReceiver } from "../../utils/voucher"; -import { useAccount, useContractRead, useContractWrite } from "wagmi"; +import { decodeVoucher } from "../../utils/voucher"; import { getDAppAddress } from "../../utils/address"; import { voucherExecutionAbi, dummyProof } from "../../utils/voucher"; -import { Address, formatEther } from "viem"; +import { useErc20Metadata, formatErc20Amount } from "../../utils/erc20"; +import { transactionStatus } from "../../utils/transactionStatus"; const WithdrawButton: FC<{ voucher: Voucher }> = ({ voucher }) => { const { data: wasExecuted, error: wasExecutedError } = useContractRead({ @@ -25,13 +34,14 @@ const WithdrawButton: FC<{ voucher: Voucher }> = ({ voucher }) => { abi: voucherExecutionAbi, functionName: "wasVoucherExecuted", args: [BigInt(voucher.input.index), BigInt(voucher.index)], + watch: true, }); const proof = voucher.proof ?? dummyProof; const { validity } = proof; const { inputIndexWithinEpoch, outputIndexWithinInput } = validity; - const { write: executeVoucher } = useContractWrite({ + const executePrepare = usePrepareContractWrite({ address: getDAppAddress(), abi: voucherExecutionAbi, functionName: "executeVoucher", @@ -49,37 +59,55 @@ const WithdrawButton: FC<{ voucher: Voucher }> = ({ voucher }) => { ], }); + const executeWrite = useContractWrite(executePrepare.config); + + const executeWait = useWaitForTransaction({ + hash: executeWrite.data?.hash, + }); + + const { disabled: executeDisabled, loading: executeLoading } = + transactionStatus(executePrepare, executeWrite, executeWait); + if (wasExecutedError !== null) { - return {wasExecutedError.message}; + return Error; } if (wasExecuted === undefined) { - return Checking execution status...; + return Loading; } if (wasExecuted) { - return Executed!; + return Executed; } if (voucher.proof === null) { - return Waiting for proof...; + return Missing proof; } - if (executeVoucher === undefined) { - return Preparing transaction...; - } - - return ; + return ( + + ); }; -const VoucherInfo: FC<{ voucher: Voucher }> = ({ voucher }) => { - const { value } = decodeVoucher(voucher); +const VoucherCard: FC<{ voucher: Voucher }> = ({ voucher }) => { + const { token, value } = decodeVoucher(voucher); + const erc20Metadata = useErc20Metadata(token); return (
- {formatEther(value)} ETH + + {formatErc20Amount(token, value, erc20Metadata)} + @@ -97,9 +125,19 @@ const VoucherList: FC<{ vouchers: Voucher[]; account: Address }> = ({ vouchers, account, }) => { - const vouchersForAccount = filterVouchersByReceiver(vouchers, account); - - if (vouchersForAccount.length == 0) { + const voucherCards = vouchers + .map((voucher) => { + const key = `voucher_${voucher.input.index}_${voucher.index}`; + const voucherCard = ; + return { voucher, voucherCard }; + }) + .filter(({ voucher }) => { + const { receiver } = decodeVoucher(voucher); + return isAddressEqual(receiver, account); + }) + .map(({ voucherCard }) => voucherCard); + + if (voucherCards.length == 0) { return (
No vouchers available for {account}! @@ -112,10 +150,7 @@ const VoucherList: FC<{ vouchers: Voucher[]; account: Address }> = ({
Available vouchers:
- {vouchersForAccount.map((voucher) => { - const key = `voucher_${voucher.input.index}_${voucher.index}`; - return ; - })} + {voucherCards} ); }; diff --git a/frontend/src/hooks/bug-buster.tsx b/frontend/src/hooks/bug-buster.tsx index 45b4d81..736c70a 100644 --- a/frontend/src/hooks/bug-buster.tsx +++ b/frontend/src/hooks/bug-buster.tsx @@ -1,6 +1,6 @@ -import { toHex, Hex } from "viem"; +import { toHex, Hex, Address } from "viem"; import { - usePrepareEtherPortalDepositEther, + usePrepareErc20PortalDepositErc20Tokens, usePrepareInputBoxAddInput, } from "./contracts"; import { @@ -17,25 +17,24 @@ function encodeAdvanceRequest(advanceRequest: AdvanceRequest): Hex { } function usePrepareBugBusterInput(advanceRequest: AdvanceRequest) { - const { config } = usePrepareInputBoxAddInput({ + return usePrepareInputBoxAddInput({ args: [getDAppAddress(), encodeAdvanceRequest(advanceRequest)], - enabled: true, }); - - return config; } -function usePrepareBugBusterETHDeposit( +function usePrepareBugBusterErc20Deposit( advanceRequest: AdvanceRequest, - valueInWei: bigint, + token: Address, + value: bigint, ) { - const { config } = usePrepareEtherPortalDepositEther({ - args: [getDAppAddress(), encodeAdvanceRequest(advanceRequest)], - value: valueInWei, - enabled: true, + return usePrepareErc20PortalDepositErc20Tokens({ + args: [ + token, + getDAppAddress(), + value, + encodeAdvanceRequest(advanceRequest), + ], }); - - return config; } export function usePrepareCreateBounty(bounty: CreateAppBounty) { @@ -47,10 +46,12 @@ export function usePrepareCreateBounty(bounty: CreateAppBounty) { export function usePrepareAddSponsorship( sponsorship: AddSponsorship, + token: Address, value: bigint, ) { - return usePrepareBugBusterETHDeposit( + return usePrepareBugBusterErc20Deposit( { kind: "AddSponsorship", payload: sponsorship }, + token, value, ); } diff --git a/frontend/src/model/inputs.ts b/frontend/src/model/inputs.ts index afc334a..1c13d7d 100644 --- a/frontend/src/model/inputs.ts +++ b/frontend/src/model/inputs.ts @@ -1,4 +1,4 @@ -import { Hex } from "viem"; +import { Address, Hex } from "viem"; import { CompletionStatus } from "./__generated__/graphql"; @@ -9,6 +9,7 @@ export interface CreateAppBounty { deadline: number; codeZipBinary?: string; codeZipPath?: string; + token: Address; } export interface AddSponsorship { diff --git a/frontend/src/model/state.ts b/frontend/src/model/state.ts index 6a78a14..377cfcf 100644 --- a/frontend/src/model/state.ts +++ b/frontend/src/model/state.ts @@ -9,6 +9,7 @@ export interface AppBounty { imgLink?: string; description: string; deadline: number; + token: Address; sponsorships: Sponsorship[] | null; exploit: Exploit | null; withdrawn: boolean; diff --git a/frontend/src/utils/erc20.tsx b/frontend/src/utils/erc20.tsx new file mode 100644 index 0000000..5c8151a --- /dev/null +++ b/frontend/src/utils/erc20.tsx @@ -0,0 +1,71 @@ +import { formatUnits, Address, zeroAddress } from "viem"; +import { Code, HoverCard } from "@mantine/core"; + +import { + useErc20Symbol, + useErc20Decimals, + useErc20BalanceOf, + useErc20Allowance, +} from "../hooks/contracts"; + +export interface Erc20Metadata { + symbol?: string; + decimals?: number; +} + +export const useErc20Metadata = (address: Address): Erc20Metadata => { + const { data: symbol } = useErc20Symbol({ address }); + const { data: decimals } = useErc20Decimals({ address }); + return { symbol, decimals }; +}; + +export const formatErc20Amount = ( + address: Address, + amount: bigint, + { symbol, decimals }: Erc20Metadata, +) => { + return ( + <> + {formatUnits(amount, decimals ?? 0)}{" "} + + + {symbol ?? "tokens"} + + + Token address: {address} + + + + ); +}; + +export interface Erc20UserDataOptions { + user?: Address; + spender?: Address; + watch?: boolean; +} + +export interface Erc20UserData { + balance?: bigint; + allowance?: bigint; +} + +export const useErc20UserData = ( + address: Address, + opts: Erc20UserDataOptions, +): Erc20UserData => { + const { user, spender, watch } = opts; + const { data: balance } = useErc20BalanceOf({ + address, + args: [user ?? zeroAddress], + enabled: user !== undefined, + watch, + }); + const { data: allowance } = useErc20Allowance({ + address, + args: [user ?? zeroAddress, spender ?? zeroAddress], + enabled: user !== undefined && spender !== undefined, + watch, + }); + return { balance, allowance }; +}; diff --git a/frontend/src/utils/form.ts b/frontend/src/utils/form.ts new file mode 100644 index 0000000..ac1a670 --- /dev/null +++ b/frontend/src/utils/form.ts @@ -0,0 +1,13 @@ +import { ReactNode } from "react"; + +type FormValidator = (value: T) => ReactNode | null; + +export const isPositiveNumber = (error?: ReactNode): FormValidator => { + return (value: string) => { + if (value !== "" && Number(value) > 0) { + return null; + } else { + return error; + } + }; +}; diff --git a/frontend/src/utils/transactionStatus.ts b/frontend/src/utils/transactionStatus.ts new file mode 100644 index 0000000..030528b --- /dev/null +++ b/frontend/src/utils/transactionStatus.ts @@ -0,0 +1,31 @@ +export type TransactionPrepareStatus = { + status: "idle" | "loading" | "error" | "success"; + fetchStatus: "fetching" | "idle" | "paused"; + error: Error | null; +}; + +export type TransactionExecuteStatus = { + status: "idle" | "loading" | "error" | "success"; + error: Error | null; +}; + +export type TransactionWaitStatus = { + status: "idle" | "loading" | "error" | "success"; + fetchStatus: "fetching" | "idle" | "paused"; + error: Error | null; +}; + +export const transactionStatus = ( + prepare: TransactionPrepareStatus, + execute: TransactionExecuteStatus, + wait: TransactionWaitStatus, +) => { + const loading = + prepare.fetchStatus === "fetching" || + execute.status === "loading" || + wait.fetchStatus === "fetching"; + + const disabled = prepare.error !== null; + + return { loading, disabled }; +}; diff --git a/frontend/src/utils/voucher.tsx b/frontend/src/utils/voucher.tsx index 3b5eb76..7ff6355 100644 --- a/frontend/src/utils/voucher.tsx +++ b/frontend/src/utils/voucher.tsx @@ -48,8 +48,8 @@ export interface Voucher { proof?: Proof; } -const withdrawEtherAbi = parseAbi([ - "function withdrawEther(address receiver, uint256 value)", +const erc20TransferAbi = parseAbi([ + "function transfer(address to, uint256 value) public returns (bool success)", ]); export const voucherExecutionAbi = parseAbi([ @@ -60,14 +60,17 @@ export const voucherExecutionAbi = parseAbi([ ]); export function decodeVoucher(voucher: Voucher) { + const { destination, payload } = voucher; + const { args } = decodeFunctionData({ - abi: withdrawEtherAbi, - data: voucher.payload, + abi: erc20TransferAbi, + data: payload, }); const [receiver, value] = args; return { + token: destination, receiver, value, }; diff --git a/shared/bug-buster.go b/shared/bug-buster.go index 88de042..071d900 100644 --- a/shared/bug-buster.go +++ b/shared/bug-buster.go @@ -25,9 +25,10 @@ func (s *BugBusterState) GetBounty(bountyIndex int) *AppBounty { type AppBounty struct { Name string `json:"name"` - ImgLink string `json:"imgLink"` // optional + ImgLink string `json:"imgLink"` // optional Description string `json:"description"` - Deadline int64 `json:"deadline"` // (unix timestamp) + Deadline int64 `json:"deadline"` // (unix timestamp) + Token common.Address `json:"token"` // ERC-20 Sponsorships []*Sponsorship `json:"sponsorships"` Exploit *Exploit `json:"exploit"` Withdrawn bool `json:"withdrawn"` @@ -77,12 +78,13 @@ type Input struct { } type CreateAppBounty struct { - Name string `json:"name"` - ImgLink string `json:"imgLink"` - Description string `json:"description"` - Deadline int64 `json:"deadline"` // (unix timestamp) - CodeZipBinary *string `json:"codeZipBinary,omitempty"` // base64? - CodeZipPath *string `json:"codeZipPath,omitempty"` + Name string `json:"name"` + ImgLink string `json:"imgLink"` + Description string `json:"description"` + Deadline int64 `json:"deadline"` // (unix timestamp) + CodeZipBinary *string `json:"codeZipBinary,omitempty"` // base64? + CodeZipPath *string `json:"codeZipPath,omitempty"` + Token common.Address `json:"token"` // ERC-20 } func (b *CreateAppBounty) Validate() error { @@ -101,7 +103,7 @@ func (b *CreateAppBounty) Validate() error { return nil } -// From portal (Ether) +// From portal (ERC-20) type AddSponsorship struct { BountyIndex int `json:"bountyIndex"` Name string `json:"name"` diff --git a/tests/tests.lua b/tests/tests.lua index 9d543b5..84e6aa6 100644 --- a/tests/tests.lua +++ b/tests/tests.lua @@ -20,10 +20,14 @@ local SPONSOR1_WALLET = "0x0000000000000000000000000000000000000101" local HACKER1_WALLET = "0x0000000000000000000000000000000000000201" local HACKER2_WALLET = "0x0000000000000000000000000000000000000202" +local CTSI_ADDRESS = "0x491604c0fdf08347dd1fa4ee062a822a5dd06b5d" +local USDC_ADDRESS = "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48" +local WETH_ADDRESS = "0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2" + local config = { - ETHER_PORTAL_ADDRESS = "0xffdbe43d4c855bf7e0f105c400a50857f53ab044", + ERC20_PORTAL_ADDRESS = "0x9c21aeb2093c32ddbc53eef24b873bdcd1ada1db", DAPP_ADDRESS_RELAY_ADDRESS = "0xf5de34d6bbc0446e2a45719e718efebaae179dae", - DAPP_ADDRESS = "0x7122cd1221c20892234186facfe8615e6743ab02", + DAPP_ADDRESS = "0xab7528bb862fb57e8a2bcd567a2e929a0be56a5e", } local machine_config = ".cartesi/image" @@ -58,10 +62,11 @@ local function inspect_input(machine, opts) }, true)) end -local function advance_ether_deposit(machine, opts) +local function advance_erc20_deposit(machine, opts) return decode_response_jsons(machine:advance_state({ - metadata = { msg_sender = fromhex(config.ETHER_PORTAL_ADDRESS), timestamp = opts.timestamp }, - payload = cartesix_encoder.encode_ether_deposit({ + metadata = { msg_sender = fromhex(config.ERC20_PORTAL_ADDRESS), timestamp = opts.timestamp }, + payload = cartesix_encoder.encode_erc20_deposit({ + contract_address = fromhex(opts.token), sender_address = fromhex(opts.sender), amount = tobe256(opts.amount), extra_data = tojson{ kind = opts.kind, payload = opts.data }, @@ -114,6 +119,7 @@ describe("tests on Lua bounty", function() name = "Lua 5.4.3 Bounty", description = "Try to crash a sandboxed Lua 5.4.3 script", deadline = bounty_deadline, + token = CTSI_ADDRESS, codeZipBinary = tobase64(readfile(bounty_code)), }, }) @@ -127,6 +133,7 @@ describe("tests on Lua bounty", function() imgLink = "", name = "Lua 5.4.3 Bounty", sponsorships = null, + token = CTSI_ADDRESS, withdrawn = false, }, }, @@ -134,7 +141,8 @@ describe("tests on Lua bounty", function() end) it("should add sponsorship from developer itself", function() - local res = advance_ether_deposit(machine, { + local res = advance_erc20_deposit(machine, { + token = CTSI_ADDRESS, sender = DEVELOPER1_WALLET, amount = 1000, kind = "AddSponsorship", @@ -163,6 +171,7 @@ describe("tests on Lua bounty", function() value = "1000", }, }, + token = CTSI_ADDRESS, withdrawn = false, }, }, @@ -170,7 +179,8 @@ describe("tests on Lua bounty", function() end) it("should add sponsorship from an external sponsor", function() - local res = advance_ether_deposit(machine, { + local res = advance_erc20_deposit(machine, { + token = CTSI_ADDRESS, sender = SPONSOR1_WALLET, amount = 2000, kind = "AddSponsorship", @@ -207,6 +217,7 @@ describe("tests on Lua bounty", function() value = "2000", }, }, + token = CTSI_ADDRESS, withdrawn = false, }, }, @@ -228,6 +239,21 @@ describe("tests on Lua bounty", function() expect.equal(res.status, "rejected") end) + it("should reject sponsorship with the wrong token", function() + local res = advance_erc20_deposit(machine, { + token = USDC_ADDRESS, + sender = DEVELOPER1_WALLET, + amount = 1000, + kind = "AddSponsorship", + timestamp = timestamp, + data = { + name = "Developer1", + bountyIndex = bounty_index, + }, + }) + expect.equal(res.status, "rejected") + end) + it("should reject sponsor withdraw before deadline", function() local res = advance_input(machine, { sender = DEVELOPER1_WALLET, @@ -304,21 +330,22 @@ describe("tests on Lua bounty", function() value = "2000", }, }, + token = CTSI_ADDRESS, withdrawn = true, }, }, }) expect.equal(res.vouchers, { { - address = fromhex(config.DAPP_ADDRESS), - payload = cartesix_encoder.encode_ether_transfer_voucher({ + address = fromhex(CTSI_ADDRESS), + payload = cartesix_encoder.encode_erc20_transfer_voucher({ destination_address = DEVELOPER1_WALLET, amount = tobe256(1000), }), }, { - address = fromhex(config.DAPP_ADDRESS), - payload = cartesix_encoder.encode_ether_transfer_voucher({ + address = fromhex(CTSI_ADDRESS), + payload = cartesix_encoder.encode_erc20_transfer_voucher({ destination_address = SPONSOR1_WALLET, amount = tobe256(2000), }), @@ -354,7 +381,8 @@ describe("tests on Lua bounty", function() end) it("should reject sponsorship after deadline", function() - local res = advance_ether_deposit(machine, { + local res = advance_erc20_deposit(machine, { + token = CTSI_ADDRESS, sender = SPONSOR1_WALLET, amount = 1000, kind = "AddSponsorship", @@ -443,6 +471,7 @@ describe("tests on SQLite bounty", function() name = "SQLite3 3.32.2 Bounty", description = "Try to crash SQLite 3.32.2 with a SQL query", deadline = bounty_deadline, + token = WETH_ADDRESS, codeZipBinary = tobase64(readfile(sqlite33202_bounty_code)), }, }) @@ -457,6 +486,7 @@ describe("tests on SQLite bounty", function() imgLink = "", name = "SQLite3 3.32.2 Bounty", sponsorships = null, + token = WETH_ADDRESS, withdrawn = false, }, }, @@ -464,7 +494,8 @@ describe("tests on SQLite bounty", function() end) it("should add sponsorship from an external sponsor", function() - local res = advance_ether_deposit(machine, { + local res = advance_erc20_deposit(machine, { + token = WETH_ADDRESS, sender = SPONSOR1_WALLET, amount = 4000, kind = "AddSponsorship", @@ -494,6 +525,7 @@ describe("tests on SQLite bounty", function() value = "4000", }, }, + token = WETH_ADDRESS, withdrawn = false, }, }, @@ -501,7 +533,8 @@ describe("tests on SQLite bounty", function() end) it("should raise an sponsorship", function() - local res = advance_ether_deposit(machine, { + local res = advance_erc20_deposit(machine, { + token = WETH_ADDRESS, sender = SPONSOR1_WALLET, amount = 5000, kind = "AddSponsorship", @@ -531,6 +564,7 @@ describe("tests on SQLite bounty", function() value = "9000", }, }, + token = WETH_ADDRESS, withdrawn = false, }, }, @@ -577,6 +611,7 @@ describe("tests on SQLite bounty", function() value = "9000", }, }, + token = WETH_ADDRESS, withdrawn = true, }, }, @@ -584,8 +619,8 @@ describe("tests on SQLite bounty", function() second_bounty_final_state = res.state.bounties[bounty_index + 1] expect.equal(res.vouchers, { { - address = fromhex(config.DAPP_ADDRESS), - payload = cartesix_encoder.encode_ether_transfer_voucher({ + address = fromhex(WETH_ADDRESS), + payload = cartesix_encoder.encode_erc20_transfer_voucher({ destination_address = HACKER1_WALLET, amount = tobe256(9000), }), @@ -620,7 +655,8 @@ describe("tests on SQLite bounty", function() end) it("should reject sponsorship after a previous exploit succeeded", function() - local res = advance_ether_deposit(machine, { + local res = advance_erc20_deposit(machine, { + token = WETH_ADDRESS, sender = SPONSOR1_WALLET, amount = 1000, kind = "AddSponsorship", @@ -649,6 +685,7 @@ describe("tests on BusyBox bounty", function() name = "BusyBox 1.36.1 Bounty", description = "Try to crash BusyBox 1.36.1", deadline = bounty_deadline, + token = USDC_ADDRESS, codeZipBinary = tobase64(readfile(sqlite33202_bounty_code)), }, }) @@ -664,6 +701,7 @@ describe("tests on BusyBox bounty", function() imgLink = "", name = "BusyBox 1.36.1 Bounty", sponsorships = null, + token = USDC_ADDRESS, withdrawn = false, }, }, @@ -702,6 +740,7 @@ describe("tests on BusyBox bounty", function() imgLink = "", name = "BusyBox 1.36.1 Bounty", sponsorships = null, + token = USDC_ADDRESS, withdrawn = true, }, }, @@ -709,8 +748,8 @@ describe("tests on BusyBox bounty", function() third_bounty_final_state = res.state.bounties[bounty_index + 1] expect.equal(res.vouchers, { { - address = fromhex(config.DAPP_ADDRESS), - payload = cartesix_encoder.encode_ether_transfer_voucher({ + address = fromhex(USDC_ADDRESS), + payload = cartesix_encoder.encode_erc20_transfer_voucher({ destination_address = HACKER1_WALLET, amount = tobe256(0), }), @@ -735,6 +774,7 @@ describe("tests on (linked) Lua bounty", function() name = "Lua 5.4.3 Bounty (linked)", description = "Try to crash a sandboxed Lua 5.4.3 script, again!", deadline = bounty_deadline, + token = WETH_ADDRESS, codeZipPath = bounty_path, }, }) @@ -751,6 +791,7 @@ describe("tests on (linked) Lua bounty", function() imgLink = "", name = "Lua 5.4.3 Bounty (linked)", sponsorships = null, + token = WETH_ADDRESS, withdrawn = false, }, },