- Goal progress
+ {progressLabel}
{progress}%
@@ -64,7 +66,7 @@ const StackCard = ({
,
+ >({
+ action,
+ functionName,
+ args,
+ errors,
+ approveAmount,
+ amount,
+ onSuccess,
+ }: {
+ action: StackTxAction;
+ functionName: TFunctionName;
+ args: ContractFunctionArgs;
+ /** Operation-specific error map (merged over the full microloans map). */
+ errors: Record;
+ /** For token-pulling actions: ensure at least this allowance first. */
+ approveAmount?: bigint;
+ /** Shown in the success modal when set. */
+ amount?: bigint;
+ /** Runs after the tx is mined, before the success modal (e.g. redirect). */
+ onSuccess?: (receipt: TransactionReceipt) => void;
+ }) => {
+ modal.setModal({
+ type: "STACK_TX_LOADING",
+ stackType: "microloan",
+ action,
+ });
+
+ try {
+ if (
+ approveAmount !== undefined &&
+ tokenAddress &&
+ allowance < approveAmount
+ ) {
+ const approveData = encodeFunctionData({
+ abi: erc20Abi,
+ functionName: "approve",
+ args: [MICROLOANS_CONTRACT_ADDRESS, MAX_UINT256],
+ });
+
+ const { hash } = await sendSponsoredTransaction({
+ to: tokenAddress,
+ data: approveData,
+ });
+
+ await waitForTxReceipt(hash);
+ }
+
+ const receipt = await sendMicroloansTx({ functionName, args });
+
+ queryClient.invalidateQueries({ queryKey: ["readContract"] });
+ queryClient.invalidateQueries({ queryKey: ["readContracts"] });
+ onSuccess?.(receipt);
+ modal.setModal({
+ type: "STACK_TX_RESULT",
+ stackType: "microloan",
+ action,
+ result: "success",
+ amount,
+ });
+ } catch (error) {
+ modal.setModal({
+ type: "STACK_TX_RESULT",
+ stackType: "microloan",
+ action,
+ result: "error",
+ msg: parseContractError(error, { ...MICROLOANS_ERRORS, ...errors }),
+ });
+ }
+ };
+
+ return { runMicroloanAction, balance };
+}
diff --git a/src/hooks/use-microloan.ts b/src/hooks/use-microloan.ts
new file mode 100644
index 00000000..0b6892c9
--- /dev/null
+++ b/src/hooks/use-microloan.ts
@@ -0,0 +1,111 @@
+import { microloansAbi } from "@/lib/abis/microloans";
+import { MICROLOANS_CONTRACT_ADDRESS } from "@/lib/constants";
+import { LoanState } from "@/lib/microloan-state";
+import { getDefaultChainId } from "@/utils/chain";
+import { Address } from "viem";
+import { useReadContract, useReadContracts } from "wagmi";
+
+const microloansContract = {
+ address: MICROLOANS_CONTRACT_ADDRESS,
+ abi: microloansAbi,
+ chainId: getDefaultChainId(),
+} as const;
+
+/**
+ * One loan's terms, status, derived state and outstanding principal, read in
+ * a single multicall.
+ */
+export function useMicroloan(loanId: bigint | undefined) {
+ const id = loanId ?? BigInt(0);
+ const result = useReadContracts({
+ contracts: [
+ { ...microloansContract, functionName: "getLoan", args: [id] },
+ { ...microloansContract, functionName: "loanState", args: [id] },
+ { ...microloansContract, functionName: "outstanding", args: [id] },
+ ],
+ allowFailure: false,
+ query: { enabled: loanId !== undefined },
+ });
+
+ const [loan, state, outstanding] = result.data ?? [];
+
+ return {
+ ...result,
+ terms: loan?.[0],
+ status: loan?.[1],
+ state: state !== undefined ? (state as LoanState) : undefined,
+ outstanding,
+ };
+}
+
+export type LoanTerms = NonNullable["terms"]>;
+export type LoanStatus = NonNullable["status"]>;
+
+export type MicroloanSummary = {
+ id: bigint;
+ terms: LoanTerms;
+ status: LoanStatus;
+ state: LoanState;
+};
+
+/**
+ * Every loan an address lent or borrows (getLenderLoans / getBorrowerLoans
+ * reverse indexes), each zipped with getLoan and loanState via one batched
+ * multicall (2 reads per loan).
+ */
+export function useAddressMicroloans(address: Address | undefined) {
+ const lenderIds = useReadContract({
+ ...microloansContract,
+ functionName: "getLenderLoans",
+ args: address ? [address] : undefined,
+ query: { enabled: !!address },
+ });
+ const borrowerIds = useReadContract({
+ ...microloansContract,
+ functionName: "getBorrowerLoans",
+ args: address ? [address] : undefined,
+ query: { enabled: !!address },
+ });
+
+ // A loan id appears in only one index per address (lender can't borrow)
+ const ids = [...(lenderIds.data ?? []), ...(borrowerIds.data ?? [])];
+
+ const { data: loanResults, isLoading: loansLoading } = useReadContracts({
+ contracts: ids.flatMap((id) => [
+ {
+ ...microloansContract,
+ functionName: "getLoan" as const,
+ args: [id] as const,
+ },
+ {
+ ...microloansContract,
+ functionName: "loanState" as const,
+ args: [id] as const,
+ },
+ ]),
+ query: { enabled: ids.length > 0 },
+ });
+
+ const loans: MicroloanSummary[] = ids.flatMap((id, index) => {
+ const loanResult = loanResults?.[index * 2];
+ const stateResult = loanResults?.[index * 2 + 1];
+ if (loanResult?.status !== "success" || stateResult?.status !== "success") {
+ return [];
+ }
+
+ const [terms, status] = loanResult.result as readonly [
+ LoanTerms,
+ LoanStatus,
+ ];
+
+ return [
+ { id, terms, status, state: Number(stateResult.result) as LoanState },
+ ];
+ });
+
+ return {
+ loans,
+ isLoading: lenderIds.isLoading || borrowerIds.isLoading || loansLoading,
+ error: lenderIds.error ?? borrowerIds.error,
+ };
+}
diff --git a/src/hooks/use-microloans-tx.ts b/src/hooks/use-microloans-tx.ts
new file mode 100644
index 00000000..ec2f878a
--- /dev/null
+++ b/src/hooks/use-microloans-tx.ts
@@ -0,0 +1,29 @@
+import { ContractFunctionName, ContractFunctionArgs } from "viem";
+import { microloansAbi } from "@/lib/abis/microloans";
+import { MICROLOANS_CONTRACT_ADDRESS } from "@/lib/constants";
+import { useSimulateAndSponsorTx } from "./use-simulate-and-sponsor-tx";
+import { useSponsoredTx } from "./use-sponsored-tx";
+
+type MicroloansAbi = typeof microloansAbi;
+
+export const useMicroloansTx = () => {
+ const { simulateAndSponsorTx } = useSimulateAndSponsorTx();
+
+ const sendMicroloansTx = async <
+ TFunctionName extends ContractFunctionName,
+ >(params: {
+ functionName: TFunctionName;
+ args: ContractFunctionArgs;
+ options?: Parameters<
+ ReturnType["sendSponsoredTransaction"]
+ >[1];
+ }) => {
+ return simulateAndSponsorTx({
+ address: MICROLOANS_CONTRACT_ADDRESS,
+ abi: microloansAbi,
+ ...params,
+ });
+ };
+
+ return { sendMicroloansTx };
+};
diff --git a/src/hooks/use-wait-for-tx-receipt.ts b/src/hooks/use-wait-for-tx-receipt.ts
index 5b60e463..72ae6a04 100644
--- a/src/hooks/use-wait-for-tx-receipt.ts
+++ b/src/hooks/use-wait-for-tx-receipt.ts
@@ -9,7 +9,7 @@ export const useWaitForTxReceipt = () => {
const config = useConfig();
const waitForTxReceipt = async (hash: Address) => {
- await waitForTransactionReceipt(config, {
+ return await waitForTransactionReceipt(config, {
hash,
chainId: getDefaultChainId(),
});
diff --git a/src/lib/abis/microloans.ts b/src/lib/abis/microloans.ts
new file mode 100644
index 00000000..47a27b94
--- /dev/null
+++ b/src/lib/abis/microloans.ts
@@ -0,0 +1,1001 @@
+export const microloansAbi = [
+ {
+ type: "constructor",
+ inputs: [],
+ stateMutability: "nonpayable",
+ },
+ {
+ type: "function",
+ name: "accept",
+ inputs: [
+ {
+ name: "_id",
+ type: "uint256",
+ internalType: "uint256",
+ },
+ {
+ name: "_termsHash",
+ type: "bytes32",
+ internalType: "bytes32",
+ },
+ ],
+ outputs: [],
+ stateMutability: "nonpayable",
+ },
+ {
+ type: "function",
+ name: "cancel",
+ inputs: [
+ {
+ name: "_id",
+ type: "uint256",
+ internalType: "uint256",
+ },
+ ],
+ outputs: [],
+ stateMutability: "nonpayable",
+ },
+ {
+ type: "function",
+ name: "collect",
+ inputs: [
+ {
+ name: "_id",
+ type: "uint256",
+ internalType: "uint256",
+ },
+ ],
+ outputs: [],
+ stateMutability: "nonpayable",
+ },
+ {
+ type: "function",
+ name: "create",
+ inputs: [
+ {
+ name: "_loanTerms",
+ type: "tuple",
+ internalType: "struct IMicroloans.LoanTerms",
+ components: [
+ {
+ name: "token",
+ type: "address",
+ internalType: "address",
+ },
+ {
+ name: "vault",
+ type: "address",
+ internalType: "address",
+ },
+ {
+ name: "principal",
+ type: "uint256",
+ internalType: "uint256",
+ },
+ {
+ name: "grant",
+ type: "uint256",
+ internalType: "uint256",
+ },
+ {
+ name: "acceptBy",
+ type: "uint256",
+ internalType: "uint256",
+ },
+ {
+ name: "repaymentPeriod",
+ type: "uint256",
+ internalType: "uint256",
+ },
+ {
+ name: "termsHash",
+ type: "bytes32",
+ internalType: "bytes32",
+ },
+ ],
+ },
+ {
+ name: "_borrower",
+ type: "address",
+ internalType: "address",
+ },
+ ],
+ outputs: [
+ {
+ name: "_id",
+ type: "uint256",
+ internalType: "uint256",
+ },
+ ],
+ stateMutability: "nonpayable",
+ },
+ {
+ type: "function",
+ name: "escrowValue",
+ inputs: [
+ {
+ name: "_id",
+ type: "uint256",
+ internalType: "uint256",
+ },
+ ],
+ outputs: [
+ {
+ name: "_amount",
+ type: "uint256",
+ internalType: "uint256",
+ },
+ ],
+ stateMutability: "view",
+ },
+ {
+ type: "function",
+ name: "extendRepayBy",
+ inputs: [
+ {
+ name: "_id",
+ type: "uint256",
+ internalType: "uint256",
+ },
+ {
+ name: "_newRepayBy",
+ type: "uint256",
+ internalType: "uint256",
+ },
+ ],
+ outputs: [],
+ stateMutability: "nonpayable",
+ },
+ {
+ type: "function",
+ name: "getBorrowerLoans",
+ inputs: [
+ {
+ name: "_borrower",
+ type: "address",
+ internalType: "address",
+ },
+ ],
+ outputs: [
+ {
+ name: "_ids",
+ type: "uint256[]",
+ internalType: "uint256[]",
+ },
+ ],
+ stateMutability: "view",
+ },
+ {
+ type: "function",
+ name: "getLenderLoans",
+ inputs: [
+ {
+ name: "_lender",
+ type: "address",
+ internalType: "address",
+ },
+ ],
+ outputs: [
+ {
+ name: "_ids",
+ type: "uint256[]",
+ internalType: "uint256[]",
+ },
+ ],
+ stateMutability: "view",
+ },
+ {
+ type: "function",
+ name: "getLoan",
+ inputs: [
+ {
+ name: "_id",
+ type: "uint256",
+ internalType: "uint256",
+ },
+ ],
+ outputs: [
+ {
+ name: "_loanTerms",
+ type: "tuple",
+ internalType: "struct IMicroloans.LoanTerms",
+ components: [
+ {
+ name: "token",
+ type: "address",
+ internalType: "address",
+ },
+ {
+ name: "vault",
+ type: "address",
+ internalType: "address",
+ },
+ {
+ name: "principal",
+ type: "uint256",
+ internalType: "uint256",
+ },
+ {
+ name: "grant",
+ type: "uint256",
+ internalType: "uint256",
+ },
+ {
+ name: "acceptBy",
+ type: "uint256",
+ internalType: "uint256",
+ },
+ {
+ name: "repaymentPeriod",
+ type: "uint256",
+ internalType: "uint256",
+ },
+ {
+ name: "termsHash",
+ type: "bytes32",
+ internalType: "bytes32",
+ },
+ ],
+ },
+ {
+ name: "_loanStatus",
+ type: "tuple",
+ internalType: "struct IMicroloans.LoanStatus",
+ components: [
+ {
+ name: "lender",
+ type: "address",
+ internalType: "address",
+ },
+ {
+ name: "borrower",
+ type: "address",
+ internalType: "address",
+ },
+ {
+ name: "acceptedAt",
+ type: "uint256",
+ internalType: "uint256",
+ },
+ {
+ name: "repayBy",
+ type: "uint256",
+ internalType: "uint256",
+ },
+ {
+ name: "disbursed",
+ type: "uint256",
+ internalType: "uint256",
+ },
+ {
+ name: "repaid",
+ type: "uint256",
+ internalType: "uint256",
+ },
+ {
+ name: "held",
+ type: "uint256",
+ internalType: "uint256",
+ },
+ {
+ name: "lenderOwed",
+ type: "uint256",
+ internalType: "uint256",
+ },
+ {
+ name: "cancelled",
+ type: "bool",
+ internalType: "bool",
+ },
+ {
+ name: "grantPaid",
+ type: "bool",
+ internalType: "bool",
+ },
+ {
+ name: "grantReclaimed",
+ type: "bool",
+ internalType: "bool",
+ },
+ ],
+ },
+ ],
+ stateMutability: "view",
+ },
+ {
+ type: "function",
+ name: "initialize",
+ inputs: [
+ {
+ name: "_owner",
+ type: "address",
+ internalType: "address",
+ },
+ ],
+ outputs: [],
+ stateMutability: "nonpayable",
+ },
+ {
+ type: "function",
+ name: "isTokenAllowed",
+ inputs: [
+ {
+ name: "_token",
+ type: "address",
+ internalType: "address",
+ },
+ ],
+ outputs: [
+ {
+ name: "_allowed",
+ type: "bool",
+ internalType: "bool",
+ },
+ ],
+ stateMutability: "view",
+ },
+ {
+ type: "function",
+ name: "isVaultAllowed",
+ inputs: [
+ {
+ name: "_vault",
+ type: "address",
+ internalType: "address",
+ },
+ ],
+ outputs: [
+ {
+ name: "_allowed",
+ type: "bool",
+ internalType: "bool",
+ },
+ ],
+ stateMutability: "view",
+ },
+ {
+ type: "function",
+ name: "loanState",
+ inputs: [
+ {
+ name: "_id",
+ type: "uint256",
+ internalType: "uint256",
+ },
+ ],
+ outputs: [
+ {
+ name: "_s",
+ type: "uint8",
+ internalType: "enum IMicroloans.LoanState",
+ },
+ ],
+ stateMutability: "view",
+ },
+ {
+ type: "function",
+ name: "nextId",
+ inputs: [],
+ outputs: [
+ {
+ name: "",
+ type: "uint256",
+ internalType: "uint256",
+ },
+ ],
+ stateMutability: "view",
+ },
+ {
+ type: "function",
+ name: "outstanding",
+ inputs: [
+ {
+ name: "_id",
+ type: "uint256",
+ internalType: "uint256",
+ },
+ ],
+ outputs: [
+ {
+ name: "_amount",
+ type: "uint256",
+ internalType: "uint256",
+ },
+ ],
+ stateMutability: "view",
+ },
+ {
+ type: "function",
+ name: "owner",
+ inputs: [],
+ outputs: [
+ {
+ name: "",
+ type: "address",
+ internalType: "address",
+ },
+ ],
+ stateMutability: "view",
+ },
+ {
+ type: "function",
+ name: "reclaimGrant",
+ inputs: [
+ {
+ name: "_id",
+ type: "uint256",
+ internalType: "uint256",
+ },
+ ],
+ outputs: [],
+ stateMutability: "nonpayable",
+ },
+ {
+ type: "function",
+ name: "releaseGrant",
+ inputs: [
+ {
+ name: "_id",
+ type: "uint256",
+ internalType: "uint256",
+ },
+ ],
+ outputs: [],
+ stateMutability: "nonpayable",
+ },
+ {
+ type: "function",
+ name: "renounceOwnership",
+ inputs: [],
+ outputs: [],
+ stateMutability: "nonpayable",
+ },
+ {
+ type: "function",
+ name: "repay",
+ inputs: [
+ {
+ name: "_id",
+ type: "uint256",
+ internalType: "uint256",
+ },
+ {
+ name: "_amount",
+ type: "uint256",
+ internalType: "uint256",
+ },
+ ],
+ outputs: [],
+ stateMutability: "nonpayable",
+ },
+ {
+ type: "function",
+ name: "setBorrower",
+ inputs: [
+ {
+ name: "_id",
+ type: "uint256",
+ internalType: "uint256",
+ },
+ {
+ name: "_borrower",
+ type: "address",
+ internalType: "address",
+ },
+ ],
+ outputs: [],
+ stateMutability: "nonpayable",
+ },
+ {
+ type: "function",
+ name: "setTokenAllowed",
+ inputs: [
+ {
+ name: "_token",
+ type: "address",
+ internalType: "address",
+ },
+ {
+ name: "_allowed",
+ type: "bool",
+ internalType: "bool",
+ },
+ ],
+ outputs: [],
+ stateMutability: "nonpayable",
+ },
+ {
+ type: "function",
+ name: "setVaultAllowed",
+ inputs: [
+ {
+ name: "_vault",
+ type: "address",
+ internalType: "address",
+ },
+ {
+ name: "_allowed",
+ type: "bool",
+ internalType: "bool",
+ },
+ ],
+ outputs: [],
+ stateMutability: "nonpayable",
+ },
+ {
+ type: "function",
+ name: "transferOwnership",
+ inputs: [
+ {
+ name: "newOwner",
+ type: "address",
+ internalType: "address",
+ },
+ ],
+ outputs: [],
+ stateMutability: "nonpayable",
+ },
+ {
+ type: "event",
+ name: "BorrowerSet",
+ inputs: [
+ {
+ name: "id",
+ type: "uint256",
+ indexed: true,
+ internalType: "uint256",
+ },
+ {
+ name: "borrower",
+ type: "address",
+ indexed: true,
+ internalType: "address",
+ },
+ ],
+ anonymous: false,
+ },
+ {
+ type: "event",
+ name: "GrantReclaimed",
+ inputs: [
+ {
+ name: "id",
+ type: "uint256",
+ indexed: true,
+ internalType: "uint256",
+ },
+ {
+ name: "amount",
+ type: "uint256",
+ indexed: false,
+ internalType: "uint256",
+ },
+ ],
+ anonymous: false,
+ },
+ {
+ type: "event",
+ name: "GrantReleased",
+ inputs: [
+ {
+ name: "id",
+ type: "uint256",
+ indexed: true,
+ internalType: "uint256",
+ },
+ {
+ name: "borrower",
+ type: "address",
+ indexed: true,
+ internalType: "address",
+ },
+ {
+ name: "amount",
+ type: "uint256",
+ indexed: false,
+ internalType: "uint256",
+ },
+ ],
+ anonymous: false,
+ },
+ {
+ type: "event",
+ name: "Initialized",
+ inputs: [
+ {
+ name: "version",
+ type: "uint64",
+ indexed: false,
+ internalType: "uint64",
+ },
+ ],
+ anonymous: false,
+ },
+ {
+ type: "event",
+ name: "LenderCollected",
+ inputs: [
+ {
+ name: "id",
+ type: "uint256",
+ indexed: true,
+ internalType: "uint256",
+ },
+ {
+ name: "lender",
+ type: "address",
+ indexed: true,
+ internalType: "address",
+ },
+ {
+ name: "amount",
+ type: "uint256",
+ indexed: false,
+ internalType: "uint256",
+ },
+ ],
+ anonymous: false,
+ },
+ {
+ type: "event",
+ name: "LoanAccepted",
+ inputs: [
+ {
+ name: "id",
+ type: "uint256",
+ indexed: true,
+ internalType: "uint256",
+ },
+ {
+ name: "borrower",
+ type: "address",
+ indexed: true,
+ internalType: "address",
+ },
+ {
+ name: "disbursed",
+ type: "uint256",
+ indexed: false,
+ internalType: "uint256",
+ },
+ {
+ name: "repayBy",
+ type: "uint256",
+ indexed: false,
+ internalType: "uint256",
+ },
+ ],
+ anonymous: false,
+ },
+ {
+ type: "event",
+ name: "LoanCancelled",
+ inputs: [
+ {
+ name: "id",
+ type: "uint256",
+ indexed: true,
+ internalType: "uint256",
+ },
+ {
+ name: "refunded",
+ type: "uint256",
+ indexed: false,
+ internalType: "uint256",
+ },
+ ],
+ anonymous: false,
+ },
+ {
+ type: "event",
+ name: "LoanCreated",
+ inputs: [
+ {
+ name: "id",
+ type: "uint256",
+ indexed: true,
+ internalType: "uint256",
+ },
+ {
+ name: "lender",
+ type: "address",
+ indexed: true,
+ internalType: "address",
+ },
+ {
+ name: "borrower",
+ type: "address",
+ indexed: true,
+ internalType: "address",
+ },
+ {
+ name: "terms",
+ type: "tuple",
+ indexed: false,
+ internalType: "struct IMicroloans.LoanTerms",
+ components: [
+ {
+ name: "token",
+ type: "address",
+ internalType: "address",
+ },
+ {
+ name: "vault",
+ type: "address",
+ internalType: "address",
+ },
+ {
+ name: "principal",
+ type: "uint256",
+ internalType: "uint256",
+ },
+ {
+ name: "grant",
+ type: "uint256",
+ internalType: "uint256",
+ },
+ {
+ name: "acceptBy",
+ type: "uint256",
+ internalType: "uint256",
+ },
+ {
+ name: "repaymentPeriod",
+ type: "uint256",
+ internalType: "uint256",
+ },
+ {
+ name: "termsHash",
+ type: "bytes32",
+ internalType: "bytes32",
+ },
+ ],
+ },
+ ],
+ anonymous: false,
+ },
+ {
+ type: "event",
+ name: "OwnershipTransferred",
+ inputs: [
+ {
+ name: "previousOwner",
+ type: "address",
+ indexed: true,
+ internalType: "address",
+ },
+ {
+ name: "newOwner",
+ type: "address",
+ indexed: true,
+ internalType: "address",
+ },
+ ],
+ anonymous: false,
+ },
+ {
+ type: "event",
+ name: "Repaid",
+ inputs: [
+ {
+ name: "id",
+ type: "uint256",
+ indexed: true,
+ internalType: "uint256",
+ },
+ {
+ name: "payer",
+ type: "address",
+ indexed: true,
+ internalType: "address",
+ },
+ {
+ name: "amount",
+ type: "uint256",
+ indexed: false,
+ internalType: "uint256",
+ },
+ {
+ name: "totalRepaid",
+ type: "uint256",
+ indexed: false,
+ internalType: "uint256",
+ },
+ ],
+ anonymous: false,
+ },
+ {
+ type: "event",
+ name: "RepayByExtended",
+ inputs: [
+ {
+ name: "id",
+ type: "uint256",
+ indexed: true,
+ internalType: "uint256",
+ },
+ {
+ name: "repayBy",
+ type: "uint256",
+ indexed: false,
+ internalType: "uint256",
+ },
+ ],
+ anonymous: false,
+ },
+ {
+ type: "event",
+ name: "TokenAllowed",
+ inputs: [
+ {
+ name: "token",
+ type: "address",
+ indexed: true,
+ internalType: "address",
+ },
+ {
+ name: "allowed",
+ type: "bool",
+ indexed: true,
+ internalType: "bool",
+ },
+ ],
+ anonymous: false,
+ },
+ {
+ type: "event",
+ name: "VaultAllowed",
+ inputs: [
+ {
+ name: "vault",
+ type: "address",
+ indexed: true,
+ internalType: "address",
+ },
+ {
+ name: "allowed",
+ type: "bool",
+ indexed: true,
+ internalType: "bool",
+ },
+ ],
+ anonymous: false,
+ },
+ {
+ type: "event",
+ name: "YieldCredited",
+ inputs: [
+ {
+ name: "id",
+ type: "uint256",
+ indexed: true,
+ internalType: "uint256",
+ },
+ {
+ name: "amount",
+ type: "uint256",
+ indexed: false,
+ internalType: "uint256",
+ },
+ ],
+ anonymous: false,
+ },
+ {
+ type: "error",
+ name: "BorrowerAlreadySet",
+ inputs: [],
+ },
+ {
+ type: "error",
+ name: "InvalidAcceptBy",
+ inputs: [],
+ },
+ {
+ type: "error",
+ name: "InvalidAmount",
+ inputs: [],
+ },
+ {
+ type: "error",
+ name: "InvalidBorrower",
+ inputs: [],
+ },
+ {
+ type: "error",
+ name: "InvalidInitialization",
+ inputs: [],
+ },
+ {
+ type: "error",
+ name: "InvalidPrincipal",
+ inputs: [],
+ },
+ {
+ type: "error",
+ name: "InvalidRepaymentPeriod",
+ inputs: [],
+ },
+ {
+ type: "error",
+ name: "InvalidState",
+ inputs: [],
+ },
+ {
+ type: "error",
+ name: "InvalidVault",
+ inputs: [],
+ },
+ {
+ type: "error",
+ name: "LoanNotFound",
+ inputs: [],
+ },
+ {
+ type: "error",
+ name: "NotBorrower",
+ inputs: [],
+ },
+ {
+ type: "error",
+ name: "NotInitializing",
+ inputs: [],
+ },
+ {
+ type: "error",
+ name: "NotLender",
+ inputs: [],
+ },
+ {
+ type: "error",
+ name: "NothingToCollect",
+ inputs: [],
+ },
+ {
+ type: "error",
+ name: "OwnableInvalidOwner",
+ inputs: [
+ {
+ name: "owner",
+ type: "address",
+ internalType: "address",
+ },
+ ],
+ },
+ {
+ type: "error",
+ name: "OwnableUnauthorizedAccount",
+ inputs: [
+ {
+ name: "account",
+ type: "address",
+ internalType: "address",
+ },
+ ],
+ },
+ {
+ type: "error",
+ name: "ReentrancyGuardReentrantCall",
+ inputs: [],
+ },
+ {
+ type: "error",
+ name: "SafeERC20FailedOperation",
+ inputs: [
+ {
+ name: "token",
+ type: "address",
+ internalType: "address",
+ },
+ ],
+ },
+ {
+ type: "error",
+ name: "TermsMismatch",
+ inputs: [],
+ },
+ {
+ type: "error",
+ name: "TokenNotAllowed",
+ inputs: [],
+ },
+] as const;
diff --git a/src/lib/constants.ts b/src/lib/constants.ts
index 1f3dcc25..6c45ccc3 100644
--- a/src/lib/constants.ts
+++ b/src/lib/constants.ts
@@ -12,3 +12,6 @@ export const AUTOMATIC_SAVING_CIRCLES_CONTRACT_ADDRESS =
export const GOAL_SAVINGS_CONTRACT_ADDRESS =
clientEnv.NEXT_PUBLIC_GOAL_SAVINGS_CONTRACT_ADDRESS as Address;
+
+export const MICROLOANS_CONTRACT_ADDRESS =
+ clientEnv.NEXT_PUBLIC_MICROLOANS_CONTRACT_ADDRESS as Address;
diff --git a/src/lib/contract-errors.ts b/src/lib/contract-errors.ts
index 2bfbe662..57dc5044 100644
--- a/src/lib/contract-errors.ts
+++ b/src/lib/contract-errors.ts
@@ -100,6 +100,42 @@ export const GOAL_SAVINGS_ERRORS: Record = {
SafeERC20FailedOperation: "Token transfer failed — check your balance.",
};
+/**
+ * Human-readable messages for every custom error in the
+ * Microloans contract ABI.
+ */
+export const MICROLOANS_ERRORS: Record = {
+ // ── Configuration ─────────────────────────────────────────────────
+ TokenNotAllowed: "This token is not allowed for microloans.",
+ InvalidVault: "This yield vault is not allowed for microloans.",
+ InvalidPrincipal: "The loan amount must be greater than zero.",
+ InvalidAcceptBy: "The offer deadline must be in the future.",
+ InvalidRepaymentPeriod:
+ "The repayment deadline must be later than the current one.",
+ InvalidBorrower:
+ "The borrower must be a valid address other than the lender.",
+ BorrowerAlreadySet: "This loan already has a borrower.",
+
+ // ── Roles / lifecycle ─────────────────────────────────────────────
+ LoanNotFound: "This loan does not exist.",
+ NotLender: "Only the lender can perform this action.",
+ NotBorrower: "Only the borrower can accept this loan.",
+ TermsMismatch:
+ "This agreement doesn't match the one the lender attached to the loan.",
+ InvalidState: "This loan can't do that in its current state.",
+ InvalidAmount: "The amount must be greater than zero.",
+ NothingToCollect: "There is nothing to collect yet.",
+
+ // ── OpenZeppelin / low-level ──────────────────────────────────────
+ InvalidInitialization: "Contract initialisation error.",
+ NotInitializing: "Contract is not in an initialisation state.",
+ ReentrancyGuardReentrantCall: "Reentrant call detected — please try again.",
+ OwnableInvalidOwner: "Invalid owner address.",
+ OwnableUnauthorizedAccount:
+ "Your account is not authorised to perform this action.",
+ SafeERC20FailedOperation: "Token transfer failed — check your balance.",
+};
+
/**
* Human-readable messages for every custom error in the
* CollectiveFundCircles contract ABI.
@@ -207,6 +243,53 @@ export const GOAL_CANCEL_ERRORS: Record = pick(
GOAL_SAVINGS_ERRORS
);
+// ── Microloan operation subsets ─────────────────────────────────────────────
+
+/** Errors that can surface when the lender creates a loan offer. */
+export const MICROLOAN_CREATE_ERRORS: Record = pick(
+ [
+ "TokenNotAllowed",
+ "InvalidVault",
+ "InvalidPrincipal",
+ "InvalidAcceptBy",
+ "InvalidRepaymentPeriod",
+ "InvalidBorrower",
+ "SafeERC20FailedOperation",
+ ],
+ MICROLOANS_ERRORS
+);
+
+/** Errors that can surface on lender actions (borrower, cancel, extend, reclaim). */
+export const MICROLOAN_LENDER_ERRORS: Record = pick(
+ [
+ "LoanNotFound",
+ "NotLender",
+ "InvalidState",
+ "InvalidBorrower",
+ "BorrowerAlreadySet",
+ "InvalidRepaymentPeriod",
+ ],
+ MICROLOANS_ERRORS
+);
+
+/** Errors that can surface when the borrower accepts a loan. */
+export const MICROLOAN_ACCEPT_ERRORS: Record = pick(
+ ["LoanNotFound", "NotBorrower", "InvalidState", "TermsMismatch"],
+ MICROLOANS_ERRORS
+);
+
+/** Errors that can surface when repaying a loan. */
+export const MICROLOAN_REPAY_ERRORS: Record = pick(
+ ["LoanNotFound", "InvalidState", "InvalidAmount", "SafeERC20FailedOperation"],
+ MICROLOANS_ERRORS
+);
+
+/** Errors that can surface when releasing the grant or collecting repayments. */
+export const MICROLOAN_PAYOUT_ERRORS: Record = pick(
+ ["LoanNotFound", "InvalidState", "NothingToCollect"],
+ MICROLOANS_ERRORS
+);
+
// ── Internal helper ─────────────────────────────────────────────────────────
function pick(
diff --git a/src/lib/env.ts b/src/lib/env.ts
index b5681d59..bf7aca78 100644
--- a/src/lib/env.ts
+++ b/src/lib/env.ts
@@ -36,6 +36,10 @@ const envSchema = z.object({
.string()
.optional()
.default(zeroAddress),
+ NEXT_PUBLIC_MICROLOANS_CONTRACT_ADDRESS: z
+ .string()
+ .optional()
+ .default(zeroAddress),
NEXT_PUBLIC_DEPOSIT_TOKEN_ADDRESS: z.string(),
// Empty string (e.g. a blank line copied from .env.local.example) must
// fall back to the default, not coerce to ""/0
@@ -108,6 +112,8 @@ const parsedSchema = envSchema.safeParse({
process.env.NEXT_PUBLIC_AUTOMATIC_SAVING_CIRCLES_CONTRACT_ADDRESS,
NEXT_PUBLIC_GOAL_SAVINGS_CONTRACT_ADDRESS:
process.env.NEXT_PUBLIC_GOAL_SAVINGS_CONTRACT_ADDRESS,
+ NEXT_PUBLIC_MICROLOANS_CONTRACT_ADDRESS:
+ process.env.NEXT_PUBLIC_MICROLOANS_CONTRACT_ADDRESS,
NEXT_PUBLIC_DEPOSIT_TOKEN_ADDRESS:
process.env.NEXT_PUBLIC_DEPOSIT_TOKEN_ADDRESS,
NEXT_PUBLIC_DEPOSIT_TOKEN_SYMBOL:
diff --git a/src/lib/features.ts b/src/lib/features.ts
index 0878fa50..979941da 100644
--- a/src/lib/features.ts
+++ b/src/lib/features.ts
@@ -15,7 +15,7 @@ import { clientEnv, isLocalEnv } from "./env";
* - local always renders every feature regardless of config
* - goalSavings is temporarily always visible for this branch's rollout
*/
-export const FEATURES = ["goalSavings"] as const;
+export const FEATURES = ["goalSavings", "microloans"] as const;
export type Feature = (typeof FEATURES)[number];
diff --git a/src/lib/microloan-state.ts b/src/lib/microloan-state.ts
new file mode 100644
index 00000000..bd1c48e8
--- /dev/null
+++ b/src/lib/microloan-state.ts
@@ -0,0 +1,62 @@
+/**
+ * Status helpers for Microloans, mirroring the lifecycle rules in
+ * IMicroloans.sol. The contract derives state on every read (loanState), so
+ * the UI never recomputes it from timestamps.
+ */
+
+/**
+ * Mirrors IMicroloans.LoanState — the on-chain `uint8` ordinals returned by
+ * loanState(). Keep this in sync with that source.
+ */
+export enum LoanState {
+ Offered = 0,
+ Expired = 1,
+ Cancelled = 2,
+ Active = 3,
+ Overdue = 4,
+ Repaid = 5,
+ Completed = 6,
+ Defaulted = 7,
+}
+
+export const LOAN_STATE_LABELS: Record = {
+ [LoanState.Offered]: "Offered",
+ [LoanState.Expired]: "Expired",
+ [LoanState.Cancelled]: "Cancelled",
+ [LoanState.Active]: "Active",
+ [LoanState.Overdue]: "Overdue",
+ [LoanState.Repaid]: "Repaid",
+ [LoanState.Completed]: "Completed",
+ [LoanState.Defaulted]: "Defaulted",
+};
+
+export const LOAN_STATE_CHIP_CLASSES: Record = {
+ [LoanState.Offered]: "border-primary-blue text-primary-blue",
+ [LoanState.Expired]: "border-surface-grey text-surface-grey",
+ [LoanState.Cancelled]: "border-surface-grey text-surface-grey",
+ [LoanState.Active]: "border-primary-blue text-primary-blue",
+ [LoanState.Overdue]: "border-system-red text-system-red",
+ [LoanState.Repaid]: "border-system-green text-system-green",
+ [LoanState.Completed]: "border-system-green text-system-green",
+ [LoanState.Defaulted]: "border-system-red text-system-red",
+};
+
+/**
+ * Whether the borrower can still repay: accepted and not fully repaid. A
+ * Defaulted loan can still be repaid (only the grant is gone).
+ */
+export function isLoanRepayable(state: LoanState, outstanding: bigint) {
+ return (
+ (state === LoanState.Active ||
+ state === LoanState.Overdue ||
+ state === LoanState.Defaulted) &&
+ outstanding > BigInt(0)
+ );
+}
+
+/** Repayment progress in percent (0-100, two decimals) for a loan. */
+export function loanRepaidPercent(repaid: bigint, disbursed: bigint): number {
+ if (disbursed === BigInt(0)) return 0;
+ if (repaid >= disbursed) return 100;
+ return Number((repaid * BigInt(10000)) / disbursed) / 100;
+}
diff --git a/src/lib/microloan-terms.ts b/src/lib/microloan-terms.ts
new file mode 100644
index 00000000..f82b1eb2
--- /dev/null
+++ b/src/lib/microloan-terms.ts
@@ -0,0 +1,13 @@
+import { Hex, keccak256, stringToBytes, zeroHash } from "viem";
+
+/**
+ * The on-chain termsHash for a plain-language loan agreement. The lender
+ * hashes the text at create; the borrower pastes the same text to accept, so
+ * the chain records exactly which terms they agreed to. Surrounding
+ * whitespace is ignored so a copy-paste round trip still matches. No text
+ * means no agreement (the zero hash).
+ */
+export function agreementHash(text: string | undefined): Hex {
+ const trimmed = text?.trim() ?? "";
+ return trimmed ? keccak256(stringToBytes(trimmed)) : zeroHash;
+}
diff --git a/src/lib/stack-types.ts b/src/lib/stack-types.ts
index fc1d18d9..95522d85 100644
--- a/src/lib/stack-types.ts
+++ b/src/lib/stack-types.ts
@@ -3,7 +3,7 @@
* its own id-space, creation flow, detail route and Supabase metadata id
* shape (see stackMetadataId below).
*/
-export const STACK_TYPES = ["rosca", "goal"] as const;
+export const STACK_TYPES = ["rosca", "goal", "microloan"] as const;
export type StackType = (typeof STACK_TYPES)[number];
@@ -13,17 +13,21 @@ export type NewStackType = Exclude;
export const STACK_TYPE_LABELS: Record = {
rosca: "Rotating savings",
goal: "Shared goal",
+ microloan: "Microloan",
};
export const STACK_TYPE_DESCRIPTIONS: Record = {
rosca:
"Deposit a fixed amount each round and take turns claiming the whole pot.",
goal: "Pool contributions toward a shared target. If the group hits the goal, funds release. If not, everyone gets their money back.",
+ microloan:
+ "Lend to one borrower with no interest. Escrow a grant that unlocks once the loan is repaid.",
};
const STACK_TYPE_DETAIL_BASE_PATHS: Record = {
rosca: "/stacks",
goal: "/goals",
+ microloan: "/microloans",
};
/** Detail-page path for a stack of the given type, e.g. `/goals/3`. */