@trustless-work/blocks 1.0.0 → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/bin/index.js +57 -0
  2. package/package.json +1 -1
  3. package/templates/deps.json +1 -1
  4. package/templates/escrows/details/Actions.tsx +21 -1
  5. package/templates/escrows/multi-release/initialize-escrow/dialog/InitializeEscrow.tsx +1 -0
  6. package/templates/escrows/multi-release/initialize-escrow/form/InitializeEscrow.tsx +1 -0
  7. package/templates/escrows/multi-release/initialize-escrow/shared/schema.ts +0 -1
  8. package/templates/escrows/multi-release/initialize-escrow/shared/useInitializeEscrow.ts +0 -2
  9. package/templates/escrows/multi-release/resolve-dispute/button/ResolveDispute.tsx +10 -20
  10. package/templates/escrows/multi-release/resolve-dispute/dialog/ResolveDispute.tsx +117 -60
  11. package/templates/escrows/multi-release/resolve-dispute/form/ResolveDispute.tsx +111 -55
  12. package/templates/escrows/multi-release/resolve-dispute/shared/schema.ts +68 -71
  13. package/templates/escrows/multi-release/resolve-dispute/shared/useResolveDispute.ts +107 -21
  14. package/templates/escrows/multi-release/update-escrow/shared/schema.ts +0 -1
  15. package/templates/escrows/multi-release/update-escrow/shared/useUpdateEscrow.ts +0 -4
  16. package/templates/escrows/multi-release/withdraw-remaining-funds/button/WithdrawRemainingFunds.tsx +85 -0
  17. package/templates/escrows/multi-release/withdraw-remaining-funds/dialog/WithdrawRemainingFunds.tsx +176 -0
  18. package/templates/escrows/multi-release/withdraw-remaining-funds/form/WithdrawRemainingFunds.tsx +153 -0
  19. package/templates/escrows/multi-release/withdraw-remaining-funds/shared/schema.ts +81 -0
  20. package/templates/escrows/multi-release/withdraw-remaining-funds/shared/useWithdrawRemainingFunds.ts +160 -0
  21. package/templates/escrows/single-multi-release/approve-milestone/button/ApproveMilestone.tsx +0 -1
  22. package/templates/escrows/single-multi-release/approve-milestone/shared/useApproveMilestone.ts +0 -1
  23. package/templates/escrows/single-release/initialize-escrow/shared/schema.ts +0 -1
  24. package/templates/escrows/single-release/initialize-escrow/shared/useInitializeEscrow.ts +0 -2
  25. package/templates/escrows/single-release/resolve-dispute/button/ResolveDispute.tsx +15 -31
  26. package/templates/escrows/single-release/resolve-dispute/dialog/ResolveDispute.tsx +116 -60
  27. package/templates/escrows/single-release/resolve-dispute/form/ResolveDispute.tsx +98 -43
  28. package/templates/escrows/single-release/resolve-dispute/shared/schema.ts +65 -68
  29. package/templates/escrows/single-release/resolve-dispute/shared/useResolveDispute.ts +100 -22
  30. package/templates/escrows/single-release/update-escrow/shared/schema.ts +0 -1
  31. package/templates/escrows/single-release/update-escrow/shared/useUpdateEscrow.ts +0 -4
  32. package/templates/tanstack/useEscrowsMutations.ts +53 -0
  33. package/templates/wallet-kit/trustlines.ts +0 -4
@@ -0,0 +1,153 @@
1
+ import * as React from "react";
2
+ import {
3
+ Form,
4
+ FormField,
5
+ FormItem,
6
+ FormLabel,
7
+ FormControl,
8
+ FormMessage,
9
+ } from "__UI_BASE__/form";
10
+ import { Input } from "__UI_BASE__/input";
11
+ import { Button } from "__UI_BASE__/button";
12
+ import { useWithdrawRemainingFunds } from "./useWithdrawRemainingFunds";
13
+ import { useEscrowContext } from "@/components/tw-blocks/providers/EscrowProvider";
14
+ import { Loader2, Trash2 } from "lucide-react";
15
+ import { formatCurrency } from "../../../../helpers/format.helper";
16
+
17
+ export const WithdrawRemainingFundsForm = () => {
18
+ const {
19
+ form,
20
+ handleSubmit,
21
+ isSubmitting,
22
+ distributions,
23
+ handleAddDistribution,
24
+ handleRemoveDistribution,
25
+ handleDistributionAddressChange,
26
+ handleDistributionAmountChange,
27
+ isAnyDistributionEmpty,
28
+ allowedAmount,
29
+ distributedSum,
30
+ isExactMatch,
31
+ difference,
32
+ } = useWithdrawRemainingFunds();
33
+ const { selectedEscrow } = useEscrowContext();
34
+
35
+ return (
36
+ <Form {...form}>
37
+ <form onSubmit={handleSubmit} className="flex flex-col space-y-6 w-full">
38
+ <FormLabel className="flex items-center my-4">
39
+ Distributions<span className="text-destructive ml-1">*</span>
40
+ </FormLabel>
41
+
42
+ {distributions.map((d, idx) => (
43
+ <div
44
+ key={`dist-${idx}`}
45
+ className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-[1fr_minmax(140px,220px)_auto] gap-3 sm:gap-4 items-end mb-2"
46
+ >
47
+ <FormField
48
+ control={form.control}
49
+ name={`distributions.${idx}.address` as const}
50
+ render={() => (
51
+ <FormItem className="sm:col-span-2 lg:col-span-1">
52
+ <FormLabel>Address</FormLabel>
53
+ <FormControl>
54
+ <Input
55
+ type="text"
56
+ placeholder="Receiver address"
57
+ value={d.address}
58
+ onChange={(e) =>
59
+ handleDistributionAddressChange(idx, e.target.value)
60
+ }
61
+ />
62
+ </FormControl>
63
+ <FormMessage />
64
+ </FormItem>
65
+ )}
66
+ />
67
+
68
+ <FormField
69
+ control={form.control}
70
+ name={`distributions.${idx}.amount` as const}
71
+ render={() => (
72
+ <FormItem>
73
+ <FormLabel>Amount</FormLabel>
74
+ <FormControl>
75
+ <Input
76
+ type="text"
77
+ inputMode="decimal"
78
+ placeholder="0.00"
79
+ value={(d.amount as string) ?? ""}
80
+ onChange={(e) => handleDistributionAmountChange(idx, e)}
81
+ />
82
+ </FormControl>
83
+ <FormMessage />
84
+ </FormItem>
85
+ )}
86
+ />
87
+
88
+ <Button
89
+ type="button"
90
+ onClick={() => handleRemoveDistribution(idx)}
91
+ className="justify-self-end self-end p-2 bg-transparent text-destructive rounded-md border-none shadow-none hover:bg-transparent hover:shadow-none hover:text-destructive focus:ring-0 active:ring-0"
92
+ disabled={distributions.length <= 2}
93
+ >
94
+ <Trash2 className="h-5 w-5" />
95
+ </Button>
96
+ </div>
97
+ ))}
98
+
99
+ <div className="flex justify-between items-center">
100
+ <Button
101
+ type="button"
102
+ variant="outline"
103
+ onClick={handleAddDistribution}
104
+ disabled={isAnyDistributionEmpty}
105
+ className="cursor-pointer"
106
+ >
107
+ Add Item
108
+ </Button>
109
+
110
+ <div className="flex items-center gap-4">
111
+ <div className="text-xs text-muted-foreground">
112
+ <p>
113
+ <span className="font-bold">Total Amount: </span>
114
+ {distributedSum.toFixed(2)} / {allowedAmount.toFixed(2)}
115
+ </p>
116
+ {!isExactMatch && (
117
+ <p className="text-destructive">
118
+ <span className="font-bold">Difference: </span>
119
+ {difference.toFixed(2)}
120
+ </p>
121
+ )}
122
+ </div>
123
+
124
+ <p className="text-xs text-muted-foreground">
125
+ <span className="font-bold">Total Balance: </span>
126
+ {formatCurrency(
127
+ selectedEscrow?.balance || 0,
128
+ selectedEscrow?.trustline.name || ""
129
+ )}
130
+ </p>
131
+ </div>
132
+ </div>
133
+
134
+ <div className="mt-4">
135
+ <Button
136
+ type="submit"
137
+ disabled={isSubmitting || isAnyDistributionEmpty || !isExactMatch}
138
+ className="cursor-pointer"
139
+ >
140
+ {isSubmitting ? (
141
+ <div className="flex items-center">
142
+ <Loader2 className="h-5 w-5 animate-spin" />
143
+ <span className="ml-2">Withdrawing...</span>
144
+ </div>
145
+ ) : (
146
+ "Withdraw"
147
+ )}
148
+ </Button>
149
+ </div>
150
+ </form>
151
+ </Form>
152
+ );
153
+ };
@@ -0,0 +1,81 @@
1
+ import { z } from "zod";
2
+ import { isValidWallet } from "../../../../wallet-kit/validators";
3
+
4
+ export const getFormSchema = () => {
5
+ const amountSchema = z
6
+ .union([z.string(), z.number()])
7
+ .refine(
8
+ (val) => {
9
+ if (typeof val === "string") {
10
+ if (val === "" || val === "." || val.endsWith(".")) {
11
+ return true; // Allow partial input
12
+ }
13
+ const numVal = Number(val);
14
+ return !isNaN(numVal) && numVal >= 0;
15
+ }
16
+ return val >= 0;
17
+ },
18
+ { message: "Amount must be 0 or greater." }
19
+ )
20
+ .refine(
21
+ (val) => {
22
+ if (typeof val === "string") {
23
+ if (val === "" || val === "." || val.endsWith(".")) {
24
+ return true; // Allow partial input
25
+ }
26
+ const numVal = Number(val);
27
+ if (isNaN(numVal)) return false;
28
+ const decimalPlaces = (numVal.toString().split(".")[1] || "").length;
29
+ return decimalPlaces <= 2;
30
+ }
31
+ const decimalPlaces = (val.toString().split(".")[1] || "").length;
32
+ return decimalPlaces <= 2;
33
+ },
34
+ { message: "Amount can have a maximum of 2 decimal places." }
35
+ );
36
+
37
+ return z
38
+ .object({
39
+ distributions: z
40
+ .array(
41
+ z.object({
42
+ address: z
43
+ .string()
44
+ .min(1, { message: "Address is required." })
45
+ .refine((addr) => isValidWallet(addr), {
46
+ message: "Invalid Stellar address.",
47
+ }),
48
+ amount: amountSchema,
49
+ })
50
+ )
51
+ .min(2, { message: "At least two distributions are required." }),
52
+ })
53
+ .superRefine((data, ctx) => {
54
+ const seen = new Map<string, number>();
55
+ data.distributions.forEach((item, idx) => {
56
+ const key = (item.address || "").trim().toUpperCase();
57
+ if (!key) return;
58
+ if (seen.has(key)) {
59
+ const firstIdx = seen.get(key)!;
60
+ ctx.addIssue({
61
+ code: z.ZodIssueCode.custom,
62
+ path: ["distributions", idx, "address"],
63
+ message: "Duplicate address. Each recipient must be unique.",
64
+ });
65
+ ctx.addIssue({
66
+ code: z.ZodIssueCode.custom,
67
+ path: ["distributions", firstIdx, "address"],
68
+ message: "Duplicate address. Each recipient must be unique.",
69
+ });
70
+ } else {
71
+ seen.set(key, idx);
72
+ }
73
+ });
74
+ });
75
+ };
76
+
77
+ export const withdrawRemainingFundsSchema = getFormSchema();
78
+
79
+ export type WithdrawRemainingFundsValues = z.infer<
80
+ typeof withdrawRemainingFundsSchema
81
+ >;
@@ -0,0 +1,160 @@
1
+ import * as React from "react";
2
+ import { useForm } from "react-hook-form";
3
+ import { zodResolver } from "@hookform/resolvers/zod";
4
+ import {
5
+ withdrawRemainingFundsSchema,
6
+ type WithdrawRemainingFundsValues,
7
+ } from "./schema";
8
+ import { toast } from "sonner";
9
+ import { WithdrawRemainingFundsPayload } from "@trustless-work/escrow";
10
+ import { useEscrowContext } from "@/components/tw-blocks/providers/EscrowProvider";
11
+ import { useEscrowsMutations } from "@/components/tw-blocks/tanstack/useEscrowsMutations";
12
+ import {
13
+ ErrorResponse,
14
+ handleError,
15
+ } from "@/components/tw-blocks/handle-errors/handle";
16
+ import { useWalletContext } from "@/components/tw-blocks/wallet-kit/WalletProvider";
17
+
18
+ type DistributionInput = { address: string; amount: string | number };
19
+
20
+ export function useWithdrawRemainingFunds() {
21
+ const { withdrawRemainingFunds } = useEscrowsMutations();
22
+ const { selectedEscrow, updateEscrow } = useEscrowContext();
23
+ const { walletAddress } = useWalletContext();
24
+
25
+ const form = useForm<WithdrawRemainingFundsValues>({
26
+ resolver: zodResolver(withdrawRemainingFundsSchema),
27
+ defaultValues: {
28
+ distributions: [
29
+ { address: "", amount: "" },
30
+ { address: "", amount: "" },
31
+ ],
32
+ },
33
+ mode: "onChange",
34
+ });
35
+
36
+ const distributions = form.watch("distributions") as DistributionInput[];
37
+
38
+ const [isSubmitting, setIsSubmitting] = React.useState(false);
39
+
40
+ const allowedAmount = React.useMemo(() => {
41
+ return Number(selectedEscrow?.balance || 0);
42
+ }, [selectedEscrow]);
43
+
44
+ const distributedSum = React.useMemo(() => {
45
+ return (distributions || []).reduce((acc, d) => {
46
+ const n = Number(d?.amount ?? 0);
47
+ return acc + (isNaN(n) ? 0 : n);
48
+ }, 0);
49
+ }, [distributions]);
50
+
51
+ const isExactMatch = React.useMemo(() => {
52
+ return Number(allowedAmount) === Number(distributedSum);
53
+ }, [allowedAmount, distributedSum]);
54
+
55
+ const difference = React.useMemo(() => {
56
+ return Math.abs(Number(allowedAmount) - Number(distributedSum));
57
+ }, [allowedAmount, distributedSum]);
58
+
59
+ const handleDistributionAddressChange = (index: number, value: string) => {
60
+ const updated = [...distributions];
61
+ updated[index] = { ...updated[index], address: value };
62
+ form.setValue("distributions", updated);
63
+ };
64
+
65
+ const handleDistributionAmountChange = (
66
+ index: number,
67
+ e: React.ChangeEvent<HTMLInputElement>
68
+ ) => {
69
+ let rawValue = e.target.value;
70
+ rawValue = rawValue.replace(/[^0-9.]/g, "");
71
+ if (rawValue.split(".").length > 2) {
72
+ rawValue = rawValue.slice(0, -1);
73
+ }
74
+ if (rawValue.includes(".")) {
75
+ const parts = rawValue.split(".");
76
+ if (parts[1] && parts[1].length > 2) {
77
+ rawValue = parts[0] + "." + parts[1].slice(0, 2);
78
+ }
79
+ }
80
+ const updated = [...distributions];
81
+ updated[index] = { ...updated[index], amount: rawValue };
82
+ form.setValue("distributions", updated);
83
+ };
84
+
85
+ const handleAddDistribution = () => {
86
+ const updated = [...distributions, { address: "", amount: "" }];
87
+ form.setValue("distributions", updated);
88
+ };
89
+
90
+ const handleRemoveDistribution = (index: number) => {
91
+ if (distributions.length <= 2) return;
92
+ const updated = distributions.filter((_, i) => i !== index);
93
+ form.setValue("distributions", updated);
94
+ };
95
+
96
+ const isAnyDistributionEmpty = React.useMemo(() => {
97
+ if (!distributions.length) return true;
98
+ const last = distributions[distributions.length - 1];
99
+ return (last.address || "").trim() === "" || (last.amount ?? "") === "";
100
+ }, [distributions]);
101
+
102
+ const handleSubmit = form.handleSubmit(async (payload) => {
103
+ try {
104
+ setIsSubmitting(true);
105
+
106
+ if (!isExactMatch) {
107
+ toast.error("The total distributions must equal the remaining amount");
108
+ return;
109
+ }
110
+
111
+ const finalPayload: WithdrawRemainingFundsPayload = {
112
+ contractId: selectedEscrow?.contractId || "",
113
+ disputeResolver: walletAddress || "",
114
+ distributions: payload.distributions.map((d) => ({
115
+ address: d.address,
116
+ amount: Number(d.amount || 0),
117
+ })) as [{ address: string; amount: number }],
118
+ };
119
+
120
+ await withdrawRemainingFunds.mutateAsync({
121
+ payload: finalPayload,
122
+ type: "multi-release",
123
+ address: walletAddress || "",
124
+ });
125
+
126
+ toast.success("Withdraw successful");
127
+
128
+ const sumDistributed = payload.distributions.reduce((acc, d) => {
129
+ const n = Number(d.amount || 0);
130
+ return acc + (isNaN(n) ? 0 : n);
131
+ }, 0);
132
+
133
+ updateEscrow({
134
+ ...selectedEscrow,
135
+ balance: (selectedEscrow?.balance || 0) - sumDistributed || 0,
136
+ });
137
+ } catch (error) {
138
+ toast.error(handleError(error as ErrorResponse).message);
139
+ } finally {
140
+ setIsSubmitting(false);
141
+ form.reset();
142
+ }
143
+ });
144
+
145
+ return {
146
+ form,
147
+ handleSubmit,
148
+ isSubmitting,
149
+ distributions,
150
+ handleAddDistribution,
151
+ handleRemoveDistribution,
152
+ handleDistributionAddressChange,
153
+ handleDistributionAmountChange,
154
+ isAnyDistributionEmpty,
155
+ allowedAmount,
156
+ distributedSum,
157
+ isExactMatch,
158
+ difference,
159
+ };
160
+ }
@@ -40,7 +40,6 @@ export const ApproveMilestoneButton = ({
40
40
  contractId: selectedEscrow?.contractId || "",
41
41
  milestoneIndex: String(milestoneIndex),
42
42
  approver: walletAddress || "",
43
- newFlag: true,
44
43
  };
45
44
 
46
45
  /**
@@ -38,7 +38,6 @@ export function useApproveMilestone() {
38
38
  contractId: selectedEscrow?.contractId || "",
39
39
  milestoneIndex: payload.milestoneIndex,
40
40
  approver: walletAddress || "",
41
- newFlag: true,
42
41
  };
43
42
 
44
43
  await approveMilestone.mutateAsync({
@@ -8,7 +8,6 @@ export const useInitializeEscrowSchema = () => {
8
8
  address: z.string().min(1, {
9
9
  message: "Trustline address is required.",
10
10
  }),
11
- decimals: z.number().default(10000000),
12
11
  }),
13
12
  roles: z.object({
14
13
  approver: z
@@ -38,7 +38,6 @@ export function useInitializeEscrow() {
38
38
  receiverMemo: "",
39
39
  trustline: {
40
40
  address: "",
41
- decimals: 10000000,
42
41
  },
43
42
  roles: {
44
43
  approver: "",
@@ -82,7 +81,6 @@ export function useInitializeEscrow() {
82
81
  receiverMemo: "123",
83
82
  trustline: {
84
83
  address: usdc?.value || "",
85
- decimals: 10000000,
86
84
  },
87
85
  roles: {
88
86
  approver: walletAddress || "",
@@ -11,14 +11,14 @@ import {
11
11
  import { useEscrowContext } from "@/components/tw-blocks/providers/EscrowProvider";
12
12
  import { Loader2 } from "lucide-react";
13
13
 
14
+ type Distribution = { address: string; amount: number };
15
+
14
16
  type ResolveDisputeButtonProps = {
15
- approverFunds: number;
16
- receiverFunds: number;
17
+ distributions: Distribution[];
17
18
  };
18
19
 
19
20
  export const ResolveDisputeButton = ({
20
- approverFunds,
21
- receiverFunds,
21
+ distributions,
22
22
  }: ResolveDisputeButtonProps) => {
23
23
  const { resolveDispute } = useEscrowsMutations();
24
24
  const { selectedEscrow, updateEscrow } = useEscrowContext();
@@ -27,42 +27,22 @@ export const ResolveDisputeButton = ({
27
27
 
28
28
  async function handleClick() {
29
29
  try {
30
- if (
31
- approverFunds == null ||
32
- Number.isNaN(approverFunds) ||
33
- receiverFunds == null ||
34
- Number.isNaN(receiverFunds)
35
- ) {
36
- toast.error("Both amounts are required");
37
- return;
38
- }
39
-
40
- if (approverFunds < 0 || receiverFunds < 0) {
41
- toast.error("Amounts must be >= 0");
30
+ const hasInvalid = distributions.some(
31
+ (d) => !d.address || Number.isNaN(d.amount) || d.amount < 0
32
+ );
33
+ if (hasInvalid) {
34
+ toast.error("Invalid distributions");
42
35
  return;
43
36
  }
44
37
 
45
38
  setIsSubmitting(true);
46
39
 
47
- /**
48
- * Create the payload for the resolve dispute mutation
49
- *
50
- * @returns The payload for the resolve dispute mutation
51
- */
52
40
  const payload: SingleReleaseResolveDisputePayload = {
53
41
  contractId: selectedEscrow?.contractId || "",
54
42
  disputeResolver: walletAddress || "",
55
- approverFunds: Number(approverFunds),
56
- receiverFunds: Number(receiverFunds),
43
+ distributions: distributions as [Distribution],
57
44
  };
58
45
 
59
- /**
60
- * Call the resolve dispute mutation
61
- *
62
- * @param payload - The payload for the resolve dispute mutation
63
- * @param type - The type of the escrow
64
- * @param address - The address of the escrow
65
- */
66
46
  await resolveDispute.mutateAsync({
67
47
  payload,
68
48
  type: "single-release",
@@ -70,6 +50,10 @@ export const ResolveDisputeButton = ({
70
50
  });
71
51
 
72
52
  toast.success("Dispute resolved successfully");
53
+ const sumDistributed = distributions.reduce(
54
+ (acc, d) => acc + Number(d.amount || 0),
55
+ 0
56
+ );
73
57
  updateEscrow({
74
58
  ...selectedEscrow,
75
59
  flags: {
@@ -77,7 +61,7 @@ export const ResolveDisputeButton = ({
77
61
  disputed: false,
78
62
  resolved: true,
79
63
  },
80
- balance: selectedEscrow?.balance || 0,
64
+ balance: (selectedEscrow?.balance || 0) - sumDistributed || 0,
81
65
  });
82
66
  } catch (error) {
83
67
  toast.error(handleError(error as ErrorResponse).message);