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
1 change: 0 additions & 1 deletion src/common/api/indexer/hooks.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import type { AxiosResponse } from "axios";

import { envConfig } from "@/common/_config/production.env-config";
import { NOOP_STRING } from "@/common/constants";
import { isAccountId, isEthereumAddress } from "@/common/lib";
import {
Expand Down
139 changes: 138 additions & 1 deletion src/common/api/indexer/sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,6 @@ export const syncApi = {
}

const result = await response.json();
console.log("Campaign synced:", result);
return { success: true, message: result.message };
} catch (error) {
console.warn("Failed to sync campaign:", error);
Expand Down Expand Up @@ -65,4 +64,142 @@ export const syncApi = {
return { success: false, message: String(error) };
}
},

/**
* Sync an account profile and recalculate donation stats
* @param accountId - The NEAR account ID
*/
async account(accountId: string): Promise<{ success: boolean; message?: string }> {
try {
const response = await fetch(`${SYNC_API_BASE_URL}/api/v1/accounts/${accountId}/sync`, {
method: "POST",
});

if (!response.ok) {
const error = await response.json().catch(() => ({}));
console.warn("Failed to sync account:", 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 account:", error);
return { success: false, message: String(error) };
}
},

/**
* Sync a list after creation or update
* @param listId - The on-chain list ID
*/
async list(listId: number | string): Promise<{ success: boolean; message?: string }> {
try {
const response = await fetch(`${SYNC_API_BASE_URL}/api/v1/lists/${listId}/sync`, {
method: "POST",
});

if (!response.ok) {
const error = await response.json().catch(() => ({}));
console.warn("Failed to sync list:", 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:", error);
return { success: false, message: String(error) };
}
},

/**
* Sync all registrations for a list
* @param listId - The on-chain list ID
*/
async listRegistrations(
listId: number | string,
): Promise<{ success: boolean; message?: string }> {
try {
const response = await fetch(
`${SYNC_API_BASE_URL}/api/v1/lists/${listId}/registrations/sync`,
{
method: "POST",
},
);

if (!response.ok) {
const error = await response.json().catch(() => ({}));
console.warn("Failed to sync list registrations:", 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 registrations:", error);
return { success: false, message: String(error) };
}
},

/**
* Sync a single registration for a list
* @param listId - The on-chain list ID
* @param registrantId - The registrant account ID
*/
async listRegistration(
listId: number | string,
registrantId: string,
): Promise<{ success: boolean; message?: string }> {
try {
const response = await fetch(
`${SYNC_API_BASE_URL}/api/v1/lists/${listId}/registrations/${registrantId}/sync`,
{
method: "POST",
},
);

if (!response.ok) {
const error = await response.json().catch(() => ({}));
console.warn("Failed to sync list registration:", 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 registration:", error);
return { success: false, message: String(error) };
}
},

/**
* Sync a direct donation after it's made
* @param txHash - Transaction hash from the donation
* @param senderId - Account ID of the donor
*/
async directDonation(
txHash: string,
senderId: string,
): Promise<{ success: boolean; message?: string }> {
try {
const response = await fetch(`${SYNC_API_BASE_URL}/api/v1/donations/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 direct donation:", 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 direct donation:", error);
return { success: false, message: String(error) };
}
},
};
155 changes: 140 additions & 15 deletions src/common/contracts/core/donation/client.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { DONATION_CONTRACT_ACCOUNT_ID } from "@/common/_config";
import { contractApi } from "@/common/blockchains/near-protocol/client";
import { contractApi, walletApi } from "@/common/blockchains/near-protocol/client";
import { FULL_TGAS } from "@/common/constants";
import type { IndivisibleUnits } from "@/common/types";

Expand All @@ -10,6 +10,11 @@ import {
DirectDonationConfig,
} from "./interfaces";

export type DirectDonateResult = {
donation: DirectDonation;
txHash: string | null;
};

const donationContractApi = contractApi({
contractId: DONATION_CONTRACT_ACCOUNT_ID,
});
Expand Down Expand Up @@ -41,25 +46,145 @@ export const get_donations_for_donor = (args: { donor_id: string }) =>
args,
});

export const donate = (args: DirectDonationArgs, depositAmountYocto: IndivisibleUnits) =>
donationContractApi.call<typeof args, DirectDonation>("donate", {
export const donate = async (
args: DirectDonationArgs,
depositAmountYocto: IndivisibleUnits,
): Promise<DirectDonateResult> => {
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 { providers } = await import("near-api-js");

const action = actionCreators.functionCall(
"donate",
args,
deposit: depositAmountYocto,
gas: FULL_TGAS,
callbackUrl: window.location.href,
});
BigInt(FULL_TGAS),
BigInt(depositAmountYocto),
);

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

if ("signAndSendTransaction" in walletAny) {
outcome = await walletAny.signAndSendTransaction({
signerId,
receiverId: DONATION_CONTRACT_ACCOUNT_ID,
actions: [action],
});
} else if ("signAndSendTransactions" in walletAny) {
const results = await walletAny.signAndSendTransactions({
transactions: [
{
receiverId: DONATION_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;
const donation = providers.getTransactionLastResult(outcome) as DirectDonation;

return { donation, txHash };
Comment on lines +94 to +97
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

Unsafe cast of transaction result to DirectDonation.

providers.getTransactionLastResult(outcome) can return null, undefined, or an unexpected shape. The as DirectDonation cast silently accepts anything, so callers may receive null as donation and later fail. Consider adding a validation check (e.g., duck-typing like donateBatch does at line 162) or at minimum handling a null result.

🤖 Prompt for AI Agents
In `@src/common/contracts/core/donation/client.ts` around lines 94 - 97,
providers.getTransactionLastResult(outcome) is being unsafely cast to
DirectDonation; replace the blind "as DirectDonation" on the donation variable
with a runtime validation and null-handling similar to donateBatch's
duck-typing: call providers.getTransactionLastResult(outcome), check the result
is non-null and has the expected DirectDonation fields (e.g., properties used
elsewhere or the DirectDonation discriminants), and only then assign/return it
as donation; if validation fails, return donation as null/undefined or throw a
clear error and include txHash in the return to keep callers safe.

};

export type DirectBatchDonateResult = {
donations: DirectDonation[];
txHash: string | null;
};

export const donateBatch = (txInputs: DirectBatchDonationItem[]) =>
donationContractApi.callMultiple<DirectDonationArgs>(
txInputs.map(({ amountYoctoNear, ...txInput }) => ({
method: "donate",
deposit: amountYoctoNear,
gas: FULL_TGAS,
export const donateBatch = async (
txInputs: DirectBatchDonationItem[],
): Promise<DirectBatchDonateResult> => {
const wallet = await walletApi.ensureWallet();
const signerId = walletApi.accountId;

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

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

// Create actions for each donation
const actions = txInputs.map(({ amountYoctoNear, args }) =>
actionCreators.functionCall("donate", args, BigInt(FULL_TGAS), BigInt(amountYoctoNear)),
);
Comment on lines +119 to 121
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 | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

rg -n "FULL_TGAS\s*=" --type=ts -B2 -A2 | head -50

Repository: PotLock/potlock-nextjs-app

Length of output: 318


🏁 Script executed:

# Also check where FULL_TGAS is imported from
rg -n "FULL_TGAS" src/common/contracts/core/donation/client.ts -B5 -A5 | head -30

Repository: PotLock/potlock-nextjs-app

Length of output: 1049


🏁 Script executed:

# Check the entire donateBatch function context around lines 119-121
cat -n src/common/contracts/core/donation/client.ts | sed -n '105,140p'

Repository: PotLock/potlock-nextjs-app

Length of output: 1505


🏁 Script executed:

# Find calls to donateBatch
rg -n "donateBatch" --type=ts -B2 -A2

Repository: PotLock/potlock-nextjs-app

Length of output: 1703


🏁 Script executed:

# Check if the signAndSendTransactions fallback path (line 133-140) has the same gas issue
cat -n src/common/contracts/core/donation/client.ts | sed -n '133,150p'

Repository: PotLock/potlock-nextjs-app

Length of output: 740


🏁 Script executed:

# Check if there's any validation on txInputs.length before calling donateBatch
rg -n "DirectBatchDonationItem" --type=ts -B3 -A3 | head -60

Repository: PotLock/potlock-nextjs-app

Length of output: 4047


Potential transaction gas overflow in batch donations.

Each action is allocated BigInt(FULL_TGAS) (300 TGas). On NEAR, the total gas for all actions in a single transaction cannot exceed 300 TGas. A batch with 2+ donations sent via signAndSendTransaction (lines 126-132) or signAndSendTransactions (lines 133-144) will exceed the per-transaction gas limit and fail.

Divide the gas budget across actions:

Proposed fix
   const actions = txInputs.map(({ amountYoctoNear, args }) =>
     actionCreators.functionCall("donate", args, BigInt(FULL_TGAS) / BigInt(txInputs.length), BigInt(amountYoctoNear)),
   );
📝 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 actions = txInputs.map(({ amountYoctoNear, args }) =>
actionCreators.functionCall("donate", args, BigInt(FULL_TGAS), BigInt(amountYoctoNear)),
);
const actions = txInputs.map(({ amountYoctoNear, args }) =>
actionCreators.functionCall("donate", args, BigInt(FULL_TGAS) / BigInt(txInputs.length), BigInt(amountYoctoNear)),
);
🤖 Prompt for AI Agents
In `@src/common/contracts/core/donation/client.ts` around lines 119 - 121, The
code currently assigns BigInt(FULL_TGAS) to every action created in txInputs.map
(via actionCreators.functionCall), which causes total gas to exceed the
per-transaction 300 TGas limit when multiple donations are batched and sent by
signAndSendTransaction / signAndSendTransactions; fix by computing a per-action
gas budget from FULL_TGAS (use BigInt division: perActionGas = BigInt(FULL_TGAS)
/ BigInt(txInputs.length)) and assign that to each functionCall, distributing
any remainder across the first few actions so the sum of all action gas <=
BigInt(FULL_TGAS); ensure the same logic is applied for both code paths that
call signAndSendTransaction and signAndSendTransactions so each transaction
respects the 300 TGas limit.


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

if ("signAndSendTransaction" in walletAny) {
// Single transaction with multiple actions
outcome = await walletAny.signAndSendTransaction({
signerId,
receiverId: DONATION_CONTRACT_ACCOUNT_ID,
actions,
});
} else if ("signAndSendTransactions" in walletAny) {
// For wallets that only support signAndSendTransactions
const results = await walletAny.signAndSendTransactions({
transactions: [
{
receiverId: DONATION_CONTRACT_ACCOUNT_ID,
actions,
},
],
});

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;

// Parse all donations from the outcome
const donations: DirectDonation[] = [];

if (outcome?.receipts_outcome) {
for (const receipt of outcome.receipts_outcome) {
const successValue = receipt?.outcome?.status?.SuccessValue;

if (successValue) {
try {
const parsed = JSON.parse(atob(successValue));

if (parsed && "recipient_id" in parsed && "donor_id" in parsed) {
donations.push(parsed as DirectDonation);
}
} catch {
// Not valid JSON, skip
}
}
}
}

// Fallback: try to get last result
if (donations.length === 0) {
try {
const lastResult = providers.getTransactionLastResult(outcome);

if (lastResult && typeof lastResult === "object" && "recipient_id" in lastResult) {
donations.push(lastResult as DirectDonation);
}
} catch {
// Ignore
}
}

return { donations, txHash };
};

export const storage_deposit = (depositAmountYocto: IndivisibleUnits) =>
donationContractApi.call<{}, IndivisibleUnits>("storage_deposit", {
deposit: depositAmountYocto,
Expand Down
1 change: 1 addition & 0 deletions src/common/contracts/core/donation/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,6 @@ import * as donationContractHooks from "./hooks";

export type * from "./hooks";
export * from "./interfaces";
export type { DirectDonateResult, DirectBatchDonateResult } from "./client";

export { donationContractClient, donationContractHooks };
13 changes: 10 additions & 3 deletions src/entities/list/components/ListDetails.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { FaHeart, FaRegHeart } from "react-icons/fa";
import { prop } from "remeda";

import { PLATFORM_NAME } from "@/common/_config";
import { List } from "@/common/api/indexer";
import { List, syncApi } from "@/common/api/indexer";
import { PLATFORM_TWITTER_ACCOUNT_ID } from "@/common/constants";
import { listsContractClient } from "@/common/contracts/core/lists";
import { truncate } from "@/common/lib";
Expand Down Expand Up @@ -77,9 +77,11 @@ export const ListDetails = ({ admins, listId, listDetails, savedUsers }: ListDet
} = useListForm();

const applyToListModal = (note: string) => {
const onChainListId = parseInt(listDetails?.on_chain_id as any);

listsContractClient
.register_batch({
list_id: parseInt(listDetails?.on_chain_id as any) as any,
list_id: onChainListId as any,
notes: note,
registrations: [
{
Expand All @@ -95,7 +97,12 @@ export const ListDetails = ({ admins, listId, listDetails, savedUsers }: ListDet
},
],
})
.then((data) => {
.then(async (data) => {
// Sync registration to indexer
if (viewer.accountId) {
await syncApi.listRegistration(onChainListId, viewer.accountId).catch(() => {});
}

setIsApplicationSuccessful(true);
})
.catch((error) => console.error("Error applying to list:", error));
Expand Down
Loading
Loading