Skip to content
Merged
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
99 changes: 99 additions & 0 deletions src/common/api/indexer/sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,105 @@ export const syncApi = {
}
},

/**
* Sync a list deletion after the owner deletes it on-chain
* @param listId - The on-chain list ID
* @param txHash - Transaction hash from the delete transaction
* @param senderId - Account ID of the list owner who deleted it
*/
async listDelete(
listId: number | string,
txHash: string,
senderId: string,
): Promise<{ success: boolean; message?: string }> {
try {
const response = await fetch(`${SYNC_API_BASE_URL}/api/v1/lists/${listId}/delete/sync`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ tx_hash: txHash, sender_id: senderId }),
});

if (!response.ok) {
const error = await response.json().catch(() => ({}));
console.warn("Failed to sync list deletion:", error);
return { success: false, message: error?.error || "Sync failed" };
}

const result = await response.json();
return { success: true, message: result.message };
} catch (error) {
console.warn("Failed to sync list deletion:", error);
return { success: false, message: String(error) };
}
},

/**
* Sync a list upvote after the user upvotes on-chain
* @param listId - The on-chain list ID
* @param txHash - Transaction hash from the upvote transaction
* @param senderId - Account ID of the user who upvoted
*/
async listUpvote(
listId: number | string,
txHash: string,
senderId: string,
): Promise<{ success: boolean; message?: string }> {
try {
const response = await fetch(`${SYNC_API_BASE_URL}/api/v1/lists/${listId}/upvote/sync`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ tx_hash: txHash, sender_id: senderId }),
});

if (!response.ok) {
const error = await response.json().catch(() => ({}));
console.warn("Failed to sync list upvote:", error);
return { success: false, message: error?.error || "Sync failed" };
}

const result = await response.json();
return { success: true, message: result.message };
} catch (error) {
console.warn("Failed to sync list upvote:", error);
return { success: false, message: String(error) };
}
},

/**
* Sync a list remove-upvote after the user removes their upvote on-chain
* @param listId - The on-chain list ID
* @param txHash - Transaction hash from the remove-upvote transaction
* @param senderId - Account ID of the user who removed their upvote
*/
async listRemoveUpvote(
listId: number | string,
txHash: string,
senderId: string,
): Promise<{ success: boolean; message?: string }> {
try {
const response = await fetch(
`${SYNC_API_BASE_URL}/api/v1/lists/${listId}/remove-upvote/sync`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ tx_hash: txHash, sender_id: senderId }),
},
);

if (!response.ok) {
const error = await response.json().catch(() => ({}));
console.warn("Failed to sync list remove-upvote:", error);
return { success: false, message: error?.error || "Sync failed" };
}

const result = await response.json();
return { success: true, message: result.message };
} catch (error) {
console.warn("Failed to sync list remove-upvote:", error);
return { success: false, message: String(error) };
}
},

/**
* Sync an account profile and recalculate donation stats
* @param accountId - The NEAR account ID
Expand Down
80 changes: 61 additions & 19 deletions src/common/contracts/core/lists/client.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { LISTS_CONTRACT_ACCOUNT_ID } from "@/common/_config";
import { contractApi as createContractApi } from "@/common/blockchains/near-protocol/client";
import { PUBLIC_GOODS_REGISTRY_LIST_ID } from "@/common/constants";
import { FULL_TGAS, PUBLIC_GOODS_REGISTRY_LIST_ID } from "@/common/constants";
import { floatToYoctoNear } from "@/common/lib";
import { AccountId } from "@/common/types";

Expand All @@ -17,6 +17,60 @@ const contractApi = createContractApi({
contractId: LISTS_CONTRACT_ACCOUNT_ID,
});

export type TxHashResult = {
txHash: string | null;
};

const callWithTxHash = async (
method: string,
args: Record<string, unknown>,
deposit?: string,
): Promise<TxHashResult> => {
const { walletApi } = await import("@/common/blockchains/near-protocol/client");
const wallet = await walletApi.ensureWallet();
const signerId = walletApi.accountId;

if (!signerId) {
throw new Error("Wallet is not signed in.");
}

const { actionCreators } = await import("@near-js/transactions");

const action = actionCreators.functionCall(
method,
args,
BigInt(FULL_TGAS),
BigInt(deposit ?? "0"),
);

let outcome: any;
const walletAny = wallet as any;

if ("signAndSendTransaction" in walletAny) {
outcome = await walletAny.signAndSendTransaction({
signerId,
receiverId: LISTS_CONTRACT_ACCOUNT_ID,
actions: [action],
});
} else if ("signAndSendTransactions" in walletAny) {
const results = await walletAny.signAndSendTransactions({
transactions: [
{
receiverId: LISTS_CONTRACT_ACCOUNT_ID,
actions: [action],
},
],
});

outcome = Array.isArray(results) ? results[0] : results;
} else {
throw new Error("Wallet does not support transaction signing");
}

const txHash = outcome?.transaction?.hash || outcome?.transaction_outcome?.id || null;
return { txHash };
};

export const get_lists = () => contractApi.view<{}, List[]>("get_lists");

export const create_list = ({
Expand Down Expand Up @@ -107,19 +161,11 @@ export const update_registered_project = (args: UpdateRegistration) =>
args,
});

export const delete_list = (args: { list_id: number }) =>
contractApi.call<typeof args, List>("delete_list", {
args,
deposit: floatToYoctoNear(0.01),
gas: "300000000000000",
});
export const delete_list = (args: { list_id: number }): Promise<TxHashResult> =>
callWithTxHash("delete_list", args, floatToYoctoNear(0.01));

export const upvote = (args: { list_id: number }) =>
contractApi.call<typeof args, List>("upvote", {
args,
deposit: floatToYoctoNear(0.01),
gas: "300000000000000",
});
export const upvote = (args: { list_id: number }): Promise<TxHashResult> =>
callWithTxHash("upvote", args, floatToYoctoNear(0.01));

export const add_admins_to_list = (args: { list_id: number; admins: Array<string> }) =>
contractApi.call<typeof args, List>("owner_add_admins", {
Expand All @@ -142,12 +188,8 @@ export const transfer_list_ownership = (args: { list_id: number; new_owner_id: s
gas: "300000000000000",
});

export const remove_upvote = (args: { list_id: number }) =>
contractApi.call<typeof args, List>("remove_upvote", {
args,
deposit: floatToYoctoNear(0.01),
gas: "300000000000000",
});
export const remove_upvote = (args: { list_id: number }): Promise<TxHashResult> =>
callWithTxHash("remove_upvote", args, floatToYoctoNear(0.01));

export const get_list_for_owner = (args: { owner_id: string }) =>
contractApi.view<typeof args, List>("get_lists_for_owner", { args });
Expand Down
23 changes: 21 additions & 2 deletions src/entities/list/components/ListCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import Image from "next/image";
import { useRouter } from "next/router";
import { FaHeart } from "react-icons/fa";

import { syncApi } from "@/common/api/indexer";
import { listsContractClient } from "@/common/contracts/core/lists";
import { truncate } from "@/common/lib";
import { LazyImage } from "@/common/ui/layout/components/LazyImage";
Expand Down Expand Up @@ -42,14 +43,32 @@ export const ListCard = ({
e.stopPropagation();

if (isUpvoted) {
listsContractClient.remove_upvote({ list_id: dataForList?.on_chain_id });
listsContractClient
.remove_upvote({ list_id: dataForList?.on_chain_id })
.then(async ({ txHash }) => {
if (txHash && viewer.accountId) {
await syncApi
.listRemoveUpvote(dataForList?.on_chain_id, txHash, viewer.accountId)
.catch(() => {});
}
})
.catch((error) => console.error("Error removing upvote:", error));

dispatch.listEditor.handleListToast({
name: truncate(dataForList?.name ?? "", 15),
type: ListFormModalType.DOWNVOTE,
});
} else {
listsContractClient.upvote({ list_id: dataForList?.on_chain_id });
listsContractClient
.upvote({ list_id: dataForList?.on_chain_id })
.then(async ({ txHash }) => {
if (txHash && viewer.accountId) {
await syncApi
.listUpvote(dataForList?.on_chain_id, txHash, viewer.accountId)
.catch(() => {});
}
})
.catch((error) => console.error("Error upvoting:", error));
Comment on lines +46 to +71
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

dataForList?.on_chain_id is passed directly without Number() conversion, unlike ListDetails.tsx.

In ListDetails.tsx (Line 135), the same value is converted via Number(listDetails.on_chain_id), but here the raw value from the API is passed to both the contract method (which expects list_id: number) and syncApi. If on_chain_id is a string from the API response, this could cause a type mismatch at the contract level.

Apply the same normalization used in ListDetails.tsx:

🔧 Proposed fix
   const handleUpvote = (e: React.MouseEvent) => {
     e.stopPropagation();
+    const onChainId = Number(dataForList?.on_chain_id);
+    if (Number.isNaN(onChainId)) return;
 
     if (isUpvoted) {
       listsContractClient
-        .remove_upvote({ list_id: dataForList?.on_chain_id })
+        .remove_upvote({ list_id: onChainId })
         .then(async ({ txHash }) => {
           if (txHash && viewer.accountId) {
             await syncApi
-              .listRemoveUpvote(dataForList?.on_chain_id, txHash, viewer.accountId)
+              .listRemoveUpvote(onChainId, txHash, viewer.accountId)
               .catch(() => {});
           }
         })
         .catch((error) => console.error("Error removing upvote:", error));
 
       // ...
     } else {
       listsContractClient
-        .upvote({ list_id: dataForList?.on_chain_id })
+        .upvote({ list_id: onChainId })
         .then(async ({ txHash }) => {
           if (txHash && viewer.accountId) {
             await syncApi
-              .listUpvote(dataForList?.on_chain_id, txHash, viewer.accountId)
+              .listUpvote(onChainId, txHash, viewer.accountId)
               .catch(() => {});
           }
         })
         .catch((error) => console.error("Error upvoting:", error));
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/entities/list/components/ListCard.tsx` around lines 46 - 71, Convert the
possibly-string on_chain_id to a number before passing to contract and sync
calls in ListCard.tsx: replace uses of dataForList?.on_chain_id with a
normalized numeric id (e.g., const listId = Number(dataForList?.on_chain_id) or
similar) and pass listId into listsContractClient.remove_upvote /
listsContractClient.upvote and into syncApi.listRemoveUpvote /
syncApi.listUpvote, keeping the rest of the promise/txHash handling intact;
mirror the normalization used in Number(listDetails.on_chain_id) from
ListDetails.tsx to ensure the contract receives a number.


dispatch.listEditor.handleListToast({
name: truncate(dataForList?.name ?? "", 15),
Expand Down
18 changes: 15 additions & 3 deletions src/entities/list/components/ListDetails.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -132,18 +132,30 @@ export const ListDetails = ({ admins, listId, listDetails, savedUsers }: ListDet
admins.includes(viewer.accountId ?? "") || listDetails.owner?.id === viewer.accountId;

const handleUpvote = () => {
const onChainId = Number(listDetails.on_chain_id);

Comment on lines +135 to +136
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Number(listDetails.on_chain_id) can produce NaN if on_chain_id is missing or non-numeric.

If listDetails.on_chain_id is undefined, null, or a non-numeric string, Number() returns NaN, which would be passed as list_id to the contract and sync API. Consider adding an early guard:

🛡️ Proposed guard
   const handleUpvote = () => {
     const onChainId = Number(listDetails.on_chain_id);
+    if (Number.isNaN(onChainId)) return;
 
     if (isUpvoted) {
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const onChainId = Number(listDetails.on_chain_id);
const onChainId = Number(listDetails.on_chain_id);
if (Number.isNaN(onChainId)) return;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/entities/list/components/ListDetails.tsx` around lines 135 - 136, The
code assigns onChainId via Number(listDetails.on_chain_id) which can yield NaN
for undefined/null/non-numeric values; change the logic in ListDetails.tsx
around the onChainId declaration to parse and validate the value (e.g., use
parseInt(listDetails.on_chain_id, 10) or Number and then check
Number.isFinite/!Number.isNaN), provide a safe fallback or early return/error UI
when invalid, and ensure any downstream use (contract calls or sync API that
consume list_id) only runs when onChainId is a valid integer.

if (isUpvoted) {
listsContractClient
.remove_upvote({ list_id: Number(listDetails.on_chain_id) })
.catch((error) => console.error("Error upvoting:", error));
.remove_upvote({ list_id: onChainId })
.then(async ({ txHash }) => {
if (txHash && viewer.accountId) {
await syncApi.listRemoveUpvote(onChainId, txHash, viewer.accountId).catch(() => {});
}
})
.catch((error) => console.error("Error removing upvote:", error));

dispatch.listEditor.handleListToast({
name: truncate(listDetails?.name ?? "", 15),
type: ListFormModalType.DOWNVOTE,
});
} else {
listsContractClient
.upvote({ list_id: Number(listDetails.on_chain_id) })
.upvote({ list_id: onChainId })
.then(async ({ txHash }) => {
if (txHash && viewer.accountId) {
await syncApi.listUpvote(onChainId, txHash, viewer.accountId).catch(() => {});
}
})
.catch((error) => console.error("Error upvoting:", error));

dispatch.listEditor.handleListToast({
Expand Down
9 changes: 8 additions & 1 deletion src/entities/list/hooks/useListForm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { contractApi } from "@/common/blockchains/near-protocol/client";
import { listsContractClient } from "@/common/contracts/core/lists";
import { floatToYoctoNear } from "@/common/lib";
import { AccountId } from "@/common/types";
import { useWalletUserSession } from "@/common/wallet";
import { AccountGroupItem, validateAccountId } from "@/entities/_shared/account";
import { useDispatch } from "@/store/hooks";

Expand All @@ -18,6 +19,7 @@ import { ListFormModalType } from "../types";
export const useListForm = () => {
const { push, query } = useRouter();
const dispatch = useDispatch();
const viewer = useWalletUserSession();
const [transferAccountField, setTransferAccountField] = useState<string>("");
const [transferAccountError, setTransferAccountError] = useState<string | undefined>("");

Expand All @@ -37,7 +39,12 @@ export const useListForm = () => {

listsContractClient
.delete_list({ list_id: id })
.then(() => {
.then(async ({ txHash }) => {
// Sync deletion to indexer
if (txHash && viewer.accountId) {
await syncApi.listDelete(id, txHash, viewer.accountId).catch(() => {});
}

push("/lists");
})
.catch((error) => {
Expand Down
Loading