Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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: 1 addition & 0 deletions src/common/api/indexer/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export * as indexerClient from "./internal/client.generated";
export * as indexer from "./hooks";
export * from "./types";
export { syncApi } from "./sync";
64 changes: 64 additions & 0 deletions src/common/api/indexer/sync.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { INDEXER_API_ENDPOINT_URL } from "@/common/_config";

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

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

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);
return { success: false, message: String(error) };
}
},

/**
* Sync a campaign donation after a donation is made
* @param campaignId - The on-chain campaign ID
* @param txHash - Transaction hash from the donation
* @param senderId - Account ID of the donor
*/
async campaignDonation(
campaignId: number | string,
txHash: string,
senderId: string,
): Promise<{ success: boolean; message?: string }> {
try {
const response = await fetch(
`${INDEXER_API_ENDPOINT_URL}/api/v1/campaigns/${campaignId}/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 campaign 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 campaign donation:", error);
return { success: false, message: String(error) };
}
},
};
61 changes: 54 additions & 7 deletions src/common/contracts/core/campaigns/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,6 @@ export const create_campaign = ({ args }: CreateCampaignParams) => {

return contractApi.callMultiple(transactions);
} else {
console.log("create campaign");
return contractApi.call<CreateCampaignParams["args"], Campaign>("create_campaign", {
args,
deposit: floatToYoctoNear(0.021),
Expand Down Expand Up @@ -117,13 +116,61 @@ export const delete_campaign = ({ args }: DeleteCampaignParams) =>
gas: FULL_TGAS,
});

export const donate = (args: CampaignDonationArgs, depositAmountYocto: IndivisibleUnits) =>
contractApi.call<CampaignDonationArgs, CampaignDonation>("donate", {
export type DonateResult = {
donation: CampaignDonation;
txHash: string | null;
};

export const donate = async (
args: CampaignDonationArgs,
depositAmountYocto: IndivisibleUnits,
): Promise<DonateResult> => {
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 { 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: CAMPAIGNS_CONTRACT_ACCOUNT_ID,
actions: [action],
});
} else if ("signAndSendTransactions" in walletAny) {
const results = await walletAny.signAndSendTransactions({
transactions: [
{
receiverId: CAMPAIGNS_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 CampaignDonation;

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

Missing null check on donation result.

providers.getTransactionLastResult(outcome) can return undefined if the transaction outcome doesn't have a successful result. This would cause donation to be undefined, which doesn't match the DonateResult type where donation is required (non-nullable CampaignDonation).

🛡️ Add validation
  const txHash = outcome?.transaction?.hash || outcome?.transaction_outcome?.id || null;
  const donation = providers.getTransactionLastResult(outcome) as CampaignDonation;

+ if (!donation) {
+   throw new Error("Unable to retrieve donation result from transaction.");
+ }
+
  return { donation, txHash };
📝 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 txHash = outcome?.transaction?.hash || outcome?.transaction_outcome?.id || null;
const donation = providers.getTransactionLastResult(outcome) as CampaignDonation;
return { donation, txHash };
const txHash = outcome?.transaction?.hash || outcome?.transaction_outcome?.id || null;
const donation = providers.getTransactionLastResult(outcome) as CampaignDonation;
if (!donation) {
throw new Error("Unable to retrieve donation result from transaction.");
}
return { donation, txHash };
🤖 Prompt for AI Agents
In `@src/common/contracts/core/campaigns/client.ts` around lines 169 - 172,
providers.getTransactionLastResult(outcome) may return undefined so you must
validate its return before casting to CampaignDonation; in the code around the
symbols outcome, providers.getTransactionLastResult and the returned
DonateResult, check the result and if it's undefined either throw a clear error
(e.g. throw new Error("missing transaction result for txHash: ...")) or return a
value that matches the DonateResult contract (do not leave donation undefined).
Ensure the code uses the computed txHash when building the error/return so the
failure can be traced.

};

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

Expand Down
16 changes: 15 additions & 1 deletion src/entities/campaign/hooks/forms.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { useRouter } from "next/router";
import { SubmitHandler, useForm, useWatch } from "react-hook-form";
import { isDeepEqual } from "remeda";

import { syncApi } from "@/common/api/indexer/sync";
import { NATIVE_TOKEN_DECIMALS, NATIVE_TOKEN_ID } from "@/common/constants";
import { campaignsContractClient } from "@/common/contracts/core/campaigns";
import type { Campaign } from "@/common/contracts/core/campaigns/interfaces";
Expand Down Expand Up @@ -310,7 +311,10 @@ export const useCampaignForm = ({ campaignId, ftId, onUpdateSuccess }: CampaignF
.update_campaign({
args: { ...args, campaign_id: campaignId },
})
.then(() => {
.then(async () => {
// Sync campaign to database
await syncApi.campaign(campaignId).catch(console.warn);

self.reset(values, { keepErrors: false });

toast({
Expand Down Expand Up @@ -347,6 +351,16 @@ export const useCampaignForm = ({ campaignId, ftId, onUpdateSuccess }: CampaignF
.then(async (newCampaign) => {
const startMs = values.start_ms ? timeToMilliseconds(values.start_ms) : undefined;

// Sync new campaign to database
if (
newCampaign &&
typeof newCampaign === "object" &&
"id" in newCampaign &&
newCampaign.id
) {
await syncApi.campaign((newCampaign as Campaign).id).catch(console.warn);
}

toast({
title: `You’ve successfully created a campaign for ${values.name}.`,
description: (() => {
Expand Down
15 changes: 13 additions & 2 deletions src/features/donation/models/effects/campaign-ft-donation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,11 @@ import type { AccountId, CampaignId } from "@/common/types";
import { DONATION_BASE_STORAGE_DEPOSIT_FLOAT } from "../../constants";
import type { DonationSubmitParams } from "../schemas";

export type CampaignFtDonationResult = {
donation: CampaignDonation;
txHash: string | null;
};

type CampaignFtDonationMulticallInputs = Pick<
DonationSubmitParams,
"amount" | "referrerAccountId" | "bypassProtocolFee" | "message" | "tokenId"
Expand All @@ -36,7 +41,7 @@ export const campaignFtDonationMulticall = async ({
bypassCreatorFee,
message,
tokenId,
}: CampaignFtDonationMulticallInputs): Promise<CampaignDonation> => {
}: CampaignFtDonationMulticallInputs): Promise<CampaignFtDonationResult> => {
const { protocol_fee_recipient_account: protocolFeeRecipientAccountId } =
await campaignsContractClient.get_config();

Expand Down Expand Up @@ -247,6 +252,12 @@ export const campaignFtDonationMulticall = async ({
),
)
.then((finalExecutionOutcomes) => {
const lastOutcome = finalExecutionOutcomes?.at(-1);
const txHash =
(lastOutcome as any)?.transaction?.hash ||
(lastOutcome as any)?.transaction_outcome?.id ||
null;

const receipt: CampaignDonation | undefined = finalExecutionOutcomes
?.at(-1)
?.receipts_outcome.filter(
Expand Down Expand Up @@ -278,7 +289,7 @@ export const campaignFtDonationMulticall = async ({
.at(0);

if (receipt !== undefined) {
return receipt;
return { donation: receipt, txHash };
} else throw new Error("Unable to determine transaction execution status.");
});
};
19 changes: 17 additions & 2 deletions src/features/donation/models/effects/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import axios from "axios";

import { syncApi } from "@/common/api/indexer";
import { RPC_NODE_URL, walletApi } from "@/common/blockchains/near-protocol/client";
import { NATIVE_TOKEN_ID } from "@/common/constants";
import { type CampaignDonation, campaignsContractClient } from "@/common/contracts/core/campaigns";
Expand Down Expand Up @@ -154,7 +155,14 @@ export const effects = (dispatch: AppDispatcher) => ({
message,
tokenId,
})
.then(dispatch.donation.success)
.then(async (result) => {
if (result.txHash && result.donation) {
await syncApi
.campaignDonation(campaignId, result.txHash, result.donation.donor_id)
.catch(() => {});
}
dispatch.donation.success(result.donation);
})
.catch((error) => {
onError(error);
dispatch.donation.failure(error);
Expand All @@ -172,7 +180,14 @@ export const effects = (dispatch: AppDispatcher) => ({

floatToYoctoNear(amount),
)
.then(dispatch.donation.success)
.then(async (result) => {
if (result.txHash && result.donation) {
await syncApi
.campaignDonation(campaignId, result.txHash, result.donation.donor_id)
.catch(() => {});
}
dispatch.donation.success(result.donation);
})
.catch((error) => {
onError(error);
dispatch.donation.failure(error);
Expand Down
Loading