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
157 changes: 149 additions & 8 deletions src/common/api/indexer/sync.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { INDEXER_API_ENDPOINT_URL } from "@/common/_config";

// Use same logic as hooks.ts - staging/production should hit dev.potlock.io
// because that's where the sync endpoints are deployed
const SYNC_API_BASE_URL =
// Campaigns only exist on dev backend, everything else is on prod
const SYNC_API_BASE_URL = INDEXER_API_ENDPOINT_URL;

const CAMPAIGNS_SYNC_API_BASE_URL =
process.env.NEXT_PUBLIC_ENV === "test" ? INDEXER_API_ENDPOINT_URL : "https://dev.potlock.io";

export const syncApi = {
Expand All @@ -12,9 +13,12 @@ export const syncApi = {
*/
async campaign(campaignId: number | string): Promise<{ success: boolean; message?: string }> {
try {
const response = await fetch(`${SYNC_API_BASE_URL}/api/v1/campaigns/${campaignId}/sync`, {
method: "POST",
});
const response = await fetch(
`${CAMPAIGNS_SYNC_API_BASE_URL}/api/v1/campaigns/${campaignId}/sync`,
{
method: "POST",
},
);

if (!response.ok) {
const error = await response.json().catch(() => ({}));
Expand All @@ -23,7 +27,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 All @@ -44,7 +47,7 @@ export const syncApi = {
): Promise<{ success: boolean; message?: string }> {
try {
const response = await fetch(
`${SYNC_API_BASE_URL}/api/v1/campaigns/${campaignId}/donations/sync`,
`${CAMPAIGNS_SYNC_API_BASE_URL}/api/v1/campaigns/${campaignId}/donations/sync`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
Expand All @@ -65,4 +68,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 };
};

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)),
);

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 };
Loading
Loading