Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 19 additions & 23 deletions src/components/ReviewTractorOrderDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,10 @@ import { useProtocolAddress } from "@/hooks/pinto/useProtocolAddress";
import useSignTractorBlueprint from "@/hooks/tractor/useSignTractorBlueprint";
import useTransaction from "@/hooks/useTransaction";
import { Blueprint, PublisherTractorExecution, Requisition, useGetBlueprintHash } from "@/lib/Tractor";
import { queryKeys } from "@/state/queryKeys";
import { cn } from "@/utils/utils";
import { CheckIcon } from "@radix-ui/react-icons";
import { useQueryClient } from "@tanstack/react-query";
import { useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import { toast } from "sonner";
Expand Down Expand Up @@ -64,6 +66,7 @@ export default function ReviewTractorOrderDialog({
const [decodeAbi, setDecodeAbi] = useState(false);
const protocolAddress = useProtocolAddress();
const navigate = useNavigate();
const queryClient = useQueryClient();

// Get order type configuration from registry
// Memoize the order config to avoid re-rendering the component on every render
Expand All @@ -81,8 +84,22 @@ export default function ReviewTractorOrderDialog({
successMessage: "Order published successfully",
errorMessage: "Failed to publish order",
successCallback: () => {
// Close the dialog after successful submission
// Invalidate all tractor queries to refresh order lists
queryClient.invalidateQueries({ queryKey: queryKeys.base.tractor });

// Close the dialog
onOpenChange(false);

// Navigate to the Field page with tractor tab active
if (orderData.type === "sow") {
navigate("/field?tab=tractor");
}

// Call the parent success callback to refresh data
onSuccess?.();

// Call the onOrderPublished callback if provided
onOrderPublished?.();
},
});

Expand Down Expand Up @@ -114,6 +131,7 @@ export default function ReviewTractorOrderDialog({

try {
setSubmitting(true);

// Check if we need to include deposit optimization calls
if (depositOptimizationCalls && depositOptimizationCalls.length > 0) {
console.debug(`Publishing requisition with ${depositOptimizationCalls.length} deposit optimization calls`);
Expand Down Expand Up @@ -147,30 +165,8 @@ export default function ReviewTractorOrderDialog({
args: [signedRequisition],
});
}

// Success handling
toast.success("Order published successfully");

// Close the dialog
onOpenChange(false);

// Navigate to the Field page with tractor tab active
if (orderData.type === "sow") {
navigate("/field?tab=tractor");
}

// Call the parent success callback to refresh data
if (onSuccess) {
onSuccess();
}

// Call the onOrderPublished callback if provided
if (onOrderPublished) {
onOrderPublished();
}
} catch (error) {
console.error("Error publishing requisition:", error);
} finally {
setSubmitting(false);
}
};
Expand Down
197 changes: 197 additions & 0 deletions src/components/Tractor/AutomateClaim/AutomateClaimExecute.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
import { TokenValue } from "@/classes/TokenValue";
import IconImage from "@/components/ui/IconImage";
import { PINTO } from "@/constants/tokens";
import { TractorRequisitionEvent } from "@/lib/Tractor";
import {
getEnabledClaimOpLabels,
getEnabledClaimOps,
transformAutomateClaimRequisitionEvent,
} from "@/lib/Tractor/claimOrder";
import { AutomateClaimBlueprintStruct } from "@/lib/Tractor/claimOrder/tractor-claim-types";
import { useTractorAutomateClaimOrderbook } from "@/state/tractor/useTractorAutomateClaimOrders";
import { usePriceData } from "@/state/usePriceData";
import useTokenData from "@/state/useTokenData";
import { formatter } from "@/utils/format";
import { Token } from "@/utils/types";
import { useCallback, useMemo } from "react";
import { useChainId, useConfig } from "wagmi";
import { ColumnConfig, ExecuteOrdersTab } from "../ExecuteOrdersTab";

type AutomateClaimOrder = TractorRequisitionEvent<AutomateClaimBlueprintStruct>;

const formatDate = formatter.dateFromTS;

const calculateUsdValue = (
amount: bigint | TokenValue,
pintoToken: Token,
prices: Map<Token, { instant: TokenValue; twa: TokenValue }>,
): { tokenAmount: TokenValue; usdValue: TokenValue; usdValueNumber: number } | null => {
const tokenAmount = amount instanceof TokenValue ? amount : TokenValue.fromBlockchain(amount, 6);
const pintoPrice = prices.get(pintoToken)?.instant;
if (!pintoPrice) return null;

const usdValue = tokenAmount.mul(pintoPrice).reDecimal(6);
return {
tokenAmount,
usdValue,
usdValueNumber: Number(usdValue.toHuman()),
};
};

const formatOperatorTip = (
amount: bigint | undefined,
pintoToken: Token,
tokenPrices: Map<Token, { instant: TokenValue; twa: TokenValue }>,
): string => {
if (amount === undefined) return "Failed to decode";

const usdData = calculateUsdValue(amount, pintoToken, tokenPrices);
if (!usdData) {
return `${formatter.number(TokenValue.fromBlockchain(amount, 6))} PINTO`;
}

return `${formatter.number(usdData.tokenAmount)} PINTO (${formatter.usd(usdData.usdValue)})`;
};

const calculateProfit = (
order: AutomateClaimOrder,
gasEstimate: bigint,
gasPrice: bigint | undefined,
mainToken: Token,
nativeToken: Token,
tokenPrices: Map<Token, { instant: TokenValue; twa: TokenValue }>,
): number => {
if (!order.decodedData) return -Infinity;

const ethPrice = tokenPrices.get(nativeToken)?.instant;
if (!ethPrice) return -Infinity;

const currentGasPrice = gasPrice || BigInt(1_000_000_000);
const gasCostInWei = gasEstimate * currentGasPrice;
const gasCostInEth = Number(gasCostInWei) / 1e18;
const ethPriceInUsd = Number(ethPrice.toNumber()) / 1e6;
const gasCostInUsd = gasCostInEth * ethPriceInUsd;

// Use the base operator tip amount
const tipData = calculateUsdValue(order.decodedData.opParams.baseOpParams.operatorTipAmount, mainToken, tokenPrices);
if (!tipData) return -Infinity;

return tipData.usdValueNumber - gasCostInUsd;
};

function getEnabledOpsLabel(order: AutomateClaimOrder): string {
if (!order.decodedData) return "Unknown";

const transformed = transformAutomateClaimRequisitionEvent(order.decodedData);
if (!transformed) return "Unknown";

const ops = getEnabledClaimOps(transformed);
const labels = getEnabledClaimOpLabels(ops);
return labels.length > 0 ? labels.join(", ") : "None";
}

export function AutomateClaimExecute() {
const config = useConfig();
const chainId = useChainId();
const blockExplorerUrl =
config.chains.find((chain) => chain.id === chainId)?.blockExplorers?.default.url ?? "https://basescan.org";

const { tokenPrices } = usePriceData();
const { mainToken, nativeToken } = useTokenData();

const { data: orders = [], isLoading, refetch } = useTractorAutomateClaimOrderbook();

const filterOrders = useCallback((orders: AutomateClaimOrder[]): AutomateClaimOrder[] => {
return orders.filter((order) => {
if (order.isCancelled) return false;
if (!order.decodedData) return false;

const tipAmount = order.decodedData.opParams.baseOpParams.operatorTipAmount;
return tipAmount > 0n;
});
}, []);

const calculateOrderProfit = useCallback(
(
order: AutomateClaimOrder,
gasEstimate: bigint,
gasPrice: bigint | undefined,
mainToken: Token,
nativeToken: Token,
tokenPrices: Map<Token, { instant: TokenValue; twa: TokenValue }>,
): number => {
return calculateProfit(order, gasEstimate, gasPrice, mainToken, nativeToken, tokenPrices);
},
[],
);

const columns: ColumnConfig<AutomateClaimOrder>[] = useMemo(
() => [
{
header: "Created At",
className: "px-0 w-44 max-w-44",
accessor: (order) => formatDate(order.timestamp),
},
{
header: "Publisher",
className: "w-32 max-w-32",
accessor: (order) => (
<a
href={`${blockExplorerUrl}/address/${order.requisition.blueprint.publisher}`}
target="_blank"
rel="noopener noreferrer"
className="text-pinto-green-4 hover:text-pinto-green-5 hover:underline text-sm"
onClick={(event) => event.stopPropagation()}
>
{`${order.requisition.blueprint.publisher.slice(0, 4)}...${order.requisition.blueprint.publisher.slice(-4)}`}
</a>
),
},
{
header: "Blueprint Hash",
className: "w-32 max-w-32",
accessor: (order) => (
<span className="text-pinto-green-4 text-sm">
{`${order.requisition.blueprintHash.slice(0, 4)}...${order.requisition.blueprintHash.slice(-3)}`}
</span>
),
},
{
header: "Enabled Ops",
className: "w-36 max-w-36",
accessor: (order) => <span className="text-sm">{getEnabledOpsLabel(order)}</span>,
},
{
header: "Operator Tip",
className: "text-right w-44 max-w-44",
accessor: (order) => (
<div className="flex items-center gap-1 text-sm place-self-end">
<IconImage src={PINTO.logoURI} alt="PINTO" size={4} />
<span>
{order.decodedData
? formatOperatorTip(order.decodedData.opParams.baseOpParams.operatorTipAmount, mainToken, tokenPrices)
: "Failed to decode"}
</span>
</div>
),
},
],
[mainToken, tokenPrices, blockExplorerUrl],
);

return (
<ExecuteOrdersTab
orders={orders}
isLoading={isLoading}
columns={columns}
filterOrders={filterOrders}
calculateProfit={calculateOrderProfit}
mainToken={mainToken}
nativeToken={nativeToken}
tokenPrices={tokenPrices}
emptyStateMessage="No active automate claim orders found"
instructionText="Select Automate Claim orders to simulate and execute for a tip."
refetchOrders={refetch}
/>
);
}
106 changes: 106 additions & 0 deletions src/components/Tractor/AutomateClaim/AutomateClaimExecutionHistory.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import { TokenValue } from "@/classes/TokenValue";
import { formatter } from "@/utils/format";
import { truncateAddress } from "@/utils/string";
import { useMemo } from "react";
import { useChainId, useConfig } from "wagmi";
import { AutomateClaimOrderData, ExecutionHistoryProps } from "../types";

export function AutomateClaimExecutionHistory({ executionHistory, orderData }: ExecutionHistoryProps) {
const config = useConfig();
const chainId = useChainId();
const blockExplorerUrl =
config.chains.find((chain) => chain.id === chainId)?.blockExplorers?.default.url ?? "https://basescan.org";

const enabledOps = useMemo(() => {
if (orderData.type !== "automateClaim") return [];
const claimData = orderData as AutomateClaimOrderData;
const ops: string[] = [];
if (claimData.mowEnabled) ops.push("Mow");
if (claimData.plantEnabled) ops.push("Plant");
if (claimData.harvestEnabled) ops.push("Harvest");
return ops;
}, [orderData]);

const totalTipsPaid = useMemo(() => {
if (orderData.type !== "automateClaim") return TokenValue.ZERO;
const claimData = orderData as AutomateClaimOrderData;
const tipAmount = claimData.operatorTip ? TokenValue.fromHuman(claimData.operatorTip, 6) : TokenValue.ZERO;
return tipAmount.mul(executionHistory.length);
}, [orderData, executionHistory.length]);

const sortedExecutions = useMemo(
() =>
[...executionHistory].sort((a, b) => {
if (a.timestamp && b.timestamp) {
return b.timestamp - a.timestamp;
}
return b.blockNumber - a.blockNumber;
}),
[executionHistory],
);

if (orderData.type !== "automateClaim") {
return null;
}

if (executionHistory.length === 0) {
return <div className="text-center text-pinto-secondary py-8">No executions yet</div>;
}

return (
<div>
{/* Summary Section */}
<div className="grid grid-cols-2 md:grid-cols-3 gap-4 mb-4 p-6">
<div className="flex flex-col">
<span className="text-sm text-pinto-secondary">Total Executions</span>
<span className="text-xl font-medium mt-3">{executionHistory.length}</span>
</div>
<div className="flex flex-col">
<span className="text-sm text-pinto-secondary">Enabled Operations</span>
<span className="text-xl font-medium mt-3">{enabledOps.join(", ") || "None"}</span>
</div>
<div className="flex flex-col">
<span className="text-sm text-pinto-secondary">Total Tips Paid</span>
<span className="text-xl font-medium mt-3">{formatter.number(totalTipsPaid)} PINTO</span>
</div>
</div>

{/* Execution Table */}
<div className="overflow-x-auto max-h-[39rem] overflow-y-auto">
<table className="relative w-full border-collapse">
<thead className="sticky top-0">
<tr className="bg-pinto-gray-1">
<th className="px-4 py-3 text-left text-pinto-secondary border-b">Execution</th>
<th className="px-4 py-3 text-right text-pinto-secondary border-b">Operator</th>
<th className="px-4 py-3 text-right text-pinto-secondary border-b min-w-[150px]">Date & Time</th>
<th className="px-4 py-3 text-right text-pinto-secondary border-b">Actions</th>
</tr>
</thead>
<tbody>
{sortedExecutions.map((execution, index) => (
<tr key={execution.transactionHash} className="hover:bg-pinto-gray-1 border-b">
<td className="px-4 py-3 font-medium">#{executionHistory.length - index}</td>
<td className="px-4 py-3 text-right text-pinto-secondary">
{truncateAddress(execution.operator, { suffix: true })}
</td>
<td className="px-4 py-3 text-right text-pinto-secondary">
{execution.timestamp ? formatter.dateFromTS(execution.timestamp) : `Block ${execution.blockNumber}`}
</td>
<td className="px-4 py-3 text-right">
<a
href={`${blockExplorerUrl}/tx/${execution.transactionHash}`}
target="_blank"
rel="noopener noreferrer"
className="text-pinto-green-4 hover:underline text-sm"
>
View Transaction
</a>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}
Loading