diff --git a/README.md b/README.md new file mode 100644 index 0000000..634c382 --- /dev/null +++ b/README.md @@ -0,0 +1,12 @@ +# TipLink Open Source + +This repository contains a collection of open-source code maintained by TipLink Corp. It provides tools and examples for integrating TipLink functionality into third-party websites and applications. The code is organized into separate folders, each with its own specific purpose and licensing. + +## Repository Structure + +- `/api`: Libraries for third-party websites to create TipLinks and access TipLink Pro features. +- `/mailer`: An example [Next.js](https://nextjs.org/) app demonstrating how to create TipLinks, Escrow TipLinks, and email them. + +--- + +TipLink Corp © [2024] \ No newline at end of file diff --git a/api/package-lock.json b/api/package-lock.json index b3e1acb..fa31760 100644 --- a/api/package-lock.json +++ b/api/package-lock.json @@ -1,12 +1,12 @@ { "name": "@tiplink/api", - "version": "0.2.6", + "version": "0.3.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@tiplink/api", - "version": "0.2.6", + "version": "0.3.1", "license": "LicenseRef-LICENSE", "dependencies": { "@coral-xyz/anchor": "^0.29.0", diff --git a/api/package.json b/api/package.json index 8fb60c8..cc09f82 100644 --- a/api/package.json +++ b/api/package.json @@ -1,6 +1,6 @@ { "name": "@tiplink/api", - "version": "0.2.6", + "version": "0.3.1", "description": "Api for creating and sending TipLinks", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/api/src/client.ts b/api/src/client.ts index bc4c8f8..64b8bbc 100644 --- a/api/src/client.ts +++ b/api/src/client.ts @@ -170,6 +170,20 @@ interface AnalyticsSummary { campaign_info: Record[]; } +// TODO better typing with prisma across repo or should we just not include all event types? +enum EventType { + CREATED="CREATED", + ACCESSED="ACCESSED", + CLAIMED="CLAIMED", + CLAIMED_BACK="CLAIMED_BACK", +} + +interface Analytic { + public_key: PublicKey; + event: EventType; + created_at: Date; +} + enum Rate { DAILY = 0, WEEKLY, @@ -545,6 +559,45 @@ export class Campaign extends TipLinkApi { return entries; } + public async getAnalytic(publicKey: string): Promise { + const analyticsRes = await this.client.fetch( + `campaigns/${this.id}/analytics`, + { public_key: publicKey, }, + ); + + let analytic: Analytic | null = null; + analyticsRes.forEach((res: Record) => { + // TODO should we display most recent created_at in category? + if (res.event == "CLAIMED" || res.event == "TRANSFERED" || res.event == "RECREATED" || res.event == "WITHDRAWN") { + analytic = { + public_key: new PublicKey(res.public_key), + event: EventType.CLAIMED, + created_at: new Date(res.created_at), + }; + } else if (res.event == "CLAIMED_BACK" && (!analytic || analytic.event !== EventType.CLAIMED)) { + analytic = { + public_key: new PublicKey(res.public_key), + event: EventType.CLAIMED_BACK, + created_at: new Date(res.created_at), + }; + } else if (res.event == "ACCESSED" && (!analytic || (analytic.event !== EventType.CLAIMED && analytic.event !== EventType.CLAIMED_BACK))) { + analytic = { + public_key: new PublicKey(res.public_key), + event: EventType.ACCESSED, + created_at: new Date(res.created_at), + }; + } else if (res.event == "CREATED" && (!analytic || (analytic.event !== EventType.CLAIMED && analytic.event !== EventType.CLAIMED_BACK && analytic.event !== EventType.ACCESSED))) { + analytic = { + public_key: new PublicKey(res.public_key), + event: EventType.CREATED, + created_at: new Date(res.created_at), + }; + } + }); + + return analytic; + } + public async getAnalytics(): Promise { // TODO clean up response here and type const analyticsRes = await this.client.fetch( @@ -983,11 +1036,33 @@ interface MintCreateParams extends Themeable { feeTransactionHash?: string; } +interface MintFindParams { + campaign_id: string; +} + interface FeeResponse { publicKey: PublicKey; feeLamports: number; } +interface IndividualLinkGetMintActivityParam { + urlSlug: string; + destination: never; +} + +interface NonIndividualLinkGetMintActivityParam { + destination: string; + urlSlug: never; +} + +type GetMintActivityParam = IndividualLinkGetMintActivityParam; // | NonIndividualLinkMintActivityParam; + +interface MintActivity { + claim_txn: string; + timestamp: Date; + destination: PublicKey; +} + class MintActions extends TipLinkApi { public constructor(params: MintActionsConstructor) { super(params.client); @@ -1234,6 +1309,61 @@ class MintActions extends TipLinkApi { return mint; } + + public async find(params: MintFindParams): Promise { + const res = ( + (await this.client.fetch( + `campaigns/${params.campaign_id}/mint/specs`, + { campaign_id: params.campaign_id}, + null, + "GET", + )) as Record // TODO type this + ); + + const mintParams: MintConstructorParams = { + client: this.client, + id: Number(res["id"]), + campaign_id: res["campaign_id"], + mintName: res["name"], + symbol: res["symbol"], + mintDescription: res["description"], + campaignName: res['name'], + collectionId: res["collection_mint"], + treeAddress: res["tree_address"], + jsonUri: res["json_uri"], + creatorPublicKey: new PublicKey(res["creator"]), + attributes: res["attributes"], + mintLimit: Number(res["mint_limit"]), + collectionUri: res["collection_uri"], + imageUrl: res["image"], + dataHost: res["imageHost"], + externalUrl: res["external_url"], + royalties: res["seller_fee_basis_points"], + primaryUrlSlug: res["primary_url_slug"], + rotatingUrlSlug: res["rotating_url_slug"], + useRotating: res["use_rotating"], + // rotating_seed_key: res["rotating_seed_key"], + rotatingTimeInterval: Number(res["rotating_time_interval"]), + totpWindow: res["totp_window"], + userClaimLimit: res["user_claim_limit"], + }; + + if ( + Object.prototype.hasOwnProperty.call( + res, + "royalties_destination", + ) && + typeof res["royalties_destination"] === "string" + ) { + mintParams["royaltiesDestination"] = new PublicKey( + res["royalties_destination"], + ); + } + + const mint = new Mint(mintParams); + + return mint; + } } export class Mint extends TipLinkApi { @@ -1310,11 +1440,39 @@ export class Mint extends TipLinkApi { public async getAnalytics(): Promise { // TODO clean up response here and type const analyticsRes = await this.client.fetch( - `campaigns/${this.campaign_id}/analytics_summary`, + `campaigns/${this.campaign_id}/mint/analytics/summary`, ); return analyticsRes; } + public async getMintActivity(params: GetMintActivityParam): Promise { + // TODO should this only work for non individual links? + + const activitiesRes = await this.client.fetch( + `campaigns/${this.campaign_id}/mint/activities`, + { url_slug: params.urlSlug }, + ); + + if (Object.prototype.hasOwnProperty.call(activitiesRes, 'activities') && activitiesRes.activities.length > 0) { + const activity = activitiesRes.activities[0]; + // @ts-ignore + if (Object.prototype.hasOwnProperty.call(activity, 'mint')) { + return { + claim_txn: activity.mint.claim_txn, + timestamp: new Date(activity.mint.timestamp), + destination: new PublicKey(activity.mint.destination), + }; + } + return { + claim_txn: activity.claim_txn, + timestamp: new Date(activity.timestamp), + destination: new PublicKey(activity.destination), + }; + } + + return null; + } + public async share(email: string, admin = false): Promise { const accounts = await this.client.fetch(`accounts_public`, { torus_id: email, diff --git a/api/src/email.ts b/api/src/email.ts index d7fadae..6d3b005 100644 --- a/api/src/email.ts +++ b/api/src/email.ts @@ -2,7 +2,7 @@ const DEFAULT_VERIFY_EMAIL_ENDPOINT = "https://email-verify.tiplink.tech/verify-email"; export const VERIFY_EMAIL_ENDPOINT = process !== undefined && process.env !== undefined - ? process.env.NEXT_PUBLIC_VERIFY_EMAIL_ENDPOINT_OVERRIDE ?? + ? process.env.NEXT_PUBLIC_VERIFY_EMAIL_ENDPOINT_OVERRIDE || DEFAULT_VERIFY_EMAIL_ENDPOINT : DEFAULT_VERIFY_EMAIL_ENDPOINT; diff --git a/api/src/enclave.ts b/api/src/enclave.ts index 19c3d7d..5b59be9 100644 --- a/api/src/enclave.ts +++ b/api/src/enclave.ts @@ -2,12 +2,12 @@ import { PublicKey } from "@solana/web3.js"; import { EscrowTipLink, TipLink } from "."; import { isEmailValid } from "./email"; -import { BACKEND_URL_BASE } from "./escrow/constants"; +import { INDEXER_URL_BASE } from "./escrow/constants"; const DEFAULT_ENCLAVE_ENDPOINT = "https://mailer.tiplink.io"; const ENCLAVE_ENDPOINT = process !== undefined && process.env !== undefined - ? process.env.NEXT_PUBLIC_ENCLAVE_ENDPOINT_OVERRIDE ?? + ? process.env.NEXT_PUBLIC_ENCLAVE_ENDPOINT_OVERRIDE || DEFAULT_ENCLAVE_ENDPOINT : DEFAULT_ENCLAVE_ENDPOINT; @@ -67,7 +67,7 @@ export async function getReceiverEmail( ): Promise { // We actually no longer hit the enclave here but we'll keep in this file // since the enclave manages this data. - const endpoint = `${BACKEND_URL_BASE}/api/v1/generated-tiplinks/${publicKey.toString()}/email`; + const endpoint = `${INDEXER_URL_BASE}/api/v1/generated-tiplinks/${publicKey.toString()}/email`; const res = await fetch(endpoint, { method: "GET", @@ -103,6 +103,7 @@ export async function getReceiverEmail( * @param toName - Optional name of the recipient for the email. * @param replyEmail - Optional email address for the recipient to reply to. * @param replyName - Optional name of the sender for the email. + * @param templateName - Optional name of the template to be used for the email. * @returns A promise that resolves when the email has been sent. * @throws Throws an error if the HTTP request fails with a non-ok status. */ @@ -112,7 +113,8 @@ export async function mail( toEmail: string, toName?: string, replyEmail?: string, - replyName?: string + replyName?: string, + templateName?: string ): Promise { if (!(await isEmailValid(toEmail))) { throw new Error("Invalid email address"); @@ -126,6 +128,7 @@ export async function mail( replyEmail, replyName, tiplinkUrl: tipLink.url.toString(), + templateName, }; const res = await fetch(url, { method: "POST", @@ -147,6 +150,7 @@ export async function mail( * @param toName - Optional name of the recipient for the email. * @param replyEmail - Optional email address for the recipient to reply to. * @param replyName - Optional name of the sender for the email. + * @param templateName - Optional name of the template to be used for the email. * @returns A promise that resolves when the email has been sent. * @throws Throws an error if the HTTP request fails with a non-ok status. */ @@ -156,6 +160,7 @@ interface MailEscrowWithObjArgs { toName?: string; replyEmail?: string; replyName?: string; + templateName?: string; } /** @@ -166,6 +171,7 @@ interface MailEscrowWithObjArgs { * @param toName - Optional name of the recipient for the email. * @param replyEmail - Optional email address for the recipient to reply to. * @param replyName - Optional name of the sender for the email. + * @param templateName - Optional name of the template to be used for the email. * @returns A promise that resolves when the email has been sent. * @throws Throws an error if the HTTP request fails with a non-ok status. */ @@ -177,6 +183,7 @@ interface MailEscrowWithValsArgs { toName?: string; replyEmail?: string; replyName?: string; + templateName?: string; } /** @@ -187,7 +194,7 @@ export async function mailEscrow( ): Promise { // TODO: Require API key / ensure deposited - const { apiKey, toName, replyEmail, replyName } = args; + const { apiKey, toName, replyEmail, replyName, templateName } = args; const { escrowTipLink } = args as MailEscrowWithObjArgs; let { toEmail, pda, receiverTipLink } = args as MailEscrowWithValsArgs; @@ -210,7 +217,7 @@ export async function mailEscrow( const receiverUrlOverride = process !== undefined && process.env !== undefined - ? process.env.NEXT_PUBLIC_ESCROW_RECEIVER_URL_OVERRIDE + ? process.env.NEXT_PUBLIC_ESCROW_RECEIVER_URL_OVERRIDE || undefined : undefined; const body = { @@ -221,6 +228,7 @@ export async function mailEscrow( pda: pda.toString(), tiplinkPublicKey: receiverTipLink.toString(), receiverUrlOverride, + templateName, }; const res = await fetch(url, { method: "POST", diff --git a/api/src/escrow/constants.ts b/api/src/escrow/constants.ts index 4a54700..3aa0d7e 100644 --- a/api/src/escrow/constants.ts +++ b/api/src/escrow/constants.ts @@ -14,14 +14,14 @@ const DEFAULT_DEPOSIT_URL_BASE = "https://tiplink-mailer.vercel.app/depositor-url"; export const DEPOSIT_URL_BASE = process !== undefined && process.env !== undefined - ? process.env.NEXT_PUBLIC_ESCROW_DEPOSITOR_URL_OVERRIDE ?? + ? process.env.NEXT_PUBLIC_ESCROW_DEPOSITOR_URL_OVERRIDE || DEFAULT_DEPOSIT_URL_BASE : DEFAULT_DEPOSIT_URL_BASE; export const PRIO_FEES_LAMPORTS = 10_000; -const DEFAULT_BACKEND_URL_BASE = "https://backend.tiplink.io"; -export const BACKEND_URL_BASE = +const DEFAULT_INDEXER_URL_BASE = "https://backend.tiplink.io"; +export const INDEXER_URL_BASE = process !== undefined && process.env !== undefined - ? process.env.NEXT_PUBLIC_BACKEND_URL_OVERRIDE ?? DEFAULT_BACKEND_URL_BASE - : DEFAULT_BACKEND_URL_BASE; + ? process.env.NEXT_PUBLIC_INDEXER_URL_OVERRIDE || DEFAULT_INDEXER_URL_BASE + : DEFAULT_INDEXER_URL_BASE; diff --git a/api/src/escrow/escrow-parsing.ts b/api/src/escrow/escrow-parsing.ts index b7420e2..9a49766 100644 --- a/api/src/escrow/escrow-parsing.ts +++ b/api/src/escrow/escrow-parsing.ts @@ -19,53 +19,13 @@ import { getMint, Mint } from "@solana/spl-token"; import { TiplinkEscrow, IDL } from "./anchor-generated/types/tiplink_escrow"; // This is different in anchor program repo import { sleep } from "../helpers"; -import { BACKEND_URL_BASE } from "./constants"; - -// TODO for Events -// 1. Differentiate between lamport / SPL -// 2. Add more helpful data -// 3. Convert to CPI event once client parsing utilities are available +import { INDEXER_URL_BASE } from "./constants"; +// These should not be used for indexing due to unreliability of Anchor events +// Perhaps this should be removed export type DepositEvent = IdlEvents["DepositEvent"]; - export type WithdrawEvent = IdlEvents["WithdrawEvent"]; -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export function asDepositEvent(e: any): DepositEvent | undefined { - try { - if ( - e.name === "DepositEvent" && - e.data && - e.data.pda instanceof PublicKey && - e.data.depositor instanceof PublicKey && - e.data.tiplink instanceof PublicKey - ) { - return e.data; - } - } catch { - // Do nothing - } - return undefined; -} - -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export function asWithdrawEvent(e: any): WithdrawEvent | undefined { - try { - if ( - e.name === "WithdrawEvent" && - e.data && - e.data.pda instanceof PublicKey && - e.data.depositor instanceof PublicKey && - e.data.tiplink instanceof PublicKey - ) { - return e.data; - } - } catch { - // Do nothing - } - return undefined; -} - export enum EscrowActionType { DepositLamport = "DepositLamport", WithdrawLamport = "WithdrawLamport", @@ -357,7 +317,7 @@ export async function parseEscrowTx( const txRes = await connection.getTransaction(sig, { maxSupportedTransactionVersion: 1, }); - if (!txRes) { + if (!txRes || txRes.meta?.err) { return []; } @@ -447,11 +407,11 @@ export async function getAllRecordedEscrowActions( ): Promise { // Limit set to 1,000 const totalSigInfos: ConfirmedSignatureInfo[] = []; - let sigInfos = await connection.getConfirmedSignaturesForAddress2(pda); + let sigInfos = await connection.getSignaturesForAddress(pda); while (sigInfos.length > 0) { totalSigInfos.push(...sigInfos); // eslint-disable-next-line no-await-in-loop - sigInfos = await connection.getConfirmedSignaturesForAddress2(pda, { + sigInfos = await connection.getSignaturesForAddress(pda, { before: sigInfos[sigInfos.length - 1].signature, }); } @@ -495,7 +455,7 @@ export async function getRecordedEscrowActionsFromVault( pda: PublicKey ): Promise { const res = await fetch( - `${BACKEND_URL_BASE}/api/v1/escrow/${pda.toBase58()}` + `${INDEXER_URL_BASE}/api/v1/escrow/${pda.toBase58()}` ); const json = await res.json(); const { data } = json; @@ -510,7 +470,7 @@ export async function getRecordedEscrowActionsFromTx( connection: Connection, sig: string ): Promise { - const res = await fetch(`${BACKEND_URL_BASE}/api/v1/transaction/${sig}`); + const res = await fetch(`${INDEXER_URL_BASE}/api/v1/transaction/${sig}`); const json = await res.json(); const { data } = json; const serializedRecordedActions = data.recordedEscrowActions; diff --git a/api/src/escrow/index.ts b/api/src/escrow/index.ts index 47e0fbb..d27121e 100644 --- a/api/src/escrow/index.ts +++ b/api/src/escrow/index.ts @@ -15,6 +15,8 @@ import { deserializeRecordedEscrowActions, getRecordedEscrowActionsFromVault, getRecordedEscrowActionsFromTx, + DepositEvent, + WithdrawEvent, } from "./escrow-parsing"; export { EscrowTipLink, @@ -35,4 +37,6 @@ export { deserializeRecordedEscrowActions, getRecordedEscrowActionsFromVault, getRecordedEscrowActionsFromTx, + DepositEvent, + WithdrawEvent, }; diff --git a/api/src/index.ts b/api/src/index.ts index be9c0bd..f300fb8 100644 --- a/api/src/index.ts +++ b/api/src/index.ts @@ -154,6 +154,8 @@ import { deserializeRecordedEscrowActions, getRecordedEscrowActionsFromVault, getRecordedEscrowActionsFromTx, + DepositEvent, + WithdrawEvent, } from "./escrow"; export { EscrowTipLink, @@ -174,6 +176,8 @@ export { deserializeRecordedEscrowActions, getRecordedEscrowActionsFromVault, getRecordedEscrowActionsFromTx, + DepositEvent, + WithdrawEvent, }; import { diff --git a/api/test/escrow/EscrowTipLink.test.ts b/api/test/escrow/EscrowTipLink.test.ts index c4f87ac..469c285 100644 --- a/api/test/escrow/EscrowTipLink.test.ts +++ b/api/test/escrow/EscrowTipLink.test.ts @@ -8,21 +8,14 @@ import { } from "@solana/web3.js"; import { getAssociatedTokenAddress } from "@solana/spl-token"; -import { - EscrowTipLink, - EscrowActionType, - parseEscrowTx, - EscrowActionDepositLamport, - EscrowActionWithdrawLamport, - EscrowActionDepositSpl, - EscrowActionWithdrawSpl, -} from "../../src"; +import { EscrowTipLink } from "../../src"; import { getDepositorKeypair, getConnection, getUsdcMint, logDepositorInfo, insertPrioFeesIxs, + retryWithDelay, } from "./helpers"; export const onchainTest = @@ -34,17 +27,6 @@ let lamportPda: PublicKey; let splEscrowTipLink: EscrowTipLink; let splPda: PublicKey; -beforeEach((done) => { - if (process.env.ONCHAIN_TESTS === "true") { - // Sleep 1 second to avoid RPC throttling - setTimeout(() => { - done(); - }, 1000); - } else { - done(); - } -}); - beforeAll(async () => { if (process.env.ONCHAIN_TESTS === "true") { await logDepositorInfo(); @@ -65,7 +47,7 @@ onchainTest("Creates lamport EscrowTipLink", async () => { apiKey: process.env.MAILER_API_KEY as string, }); - // Check + // Check object expect(lamportEscrowTipLink.amount).toBe(amount); expect(lamportEscrowTipLink.toEmail).toBe(toEmail); expect(lamportEscrowTipLink.depositor).toBe(depositor); @@ -80,55 +62,43 @@ onchainTest( const tx = await lamportEscrowTipLink.depositTx(connection); insertPrioFeesIxs(tx); - const sig = await sendAndConfirmTransaction( - connection, - tx, - [depositorKeypair], - { commitment: "confirmed" } - ); + await sendAndConfirmTransaction(connection, tx, [depositorKeypair], { + commitment: "confirmed", + }); - // Check on-chain data + // Check object expect(lamportEscrowTipLink.pda).toBeDefined(); lamportPda = lamportEscrowTipLink.pda as PublicKey; expect(lamportPda).toBeInstanceOf(PublicKey); expect(lamportEscrowTipLink.depositorUrl).toBeInstanceOf(URL); - - // Check parsing - const recordedActions = await parseEscrowTx(connection, sig); - expect(recordedActions.length).toBe(1); - const recordedAction = recordedActions[0]; - expect(recordedAction.txSig).toBe(sig); - let { action } = recordedAction; - expect(action.type).toBe(EscrowActionType.DepositLamport); - action = action as EscrowActionDepositLamport; - expect(action.depositor).toStrictEqual(depositorKeypair.publicKey); - expect(action.pda).toStrictEqual(lamportEscrowTipLink.pda); - expect(action.receiverTipLink).toStrictEqual( - lamportEscrowTipLink.receiverTipLink - ); - expect(action.amount).toBe(lamportEscrowTipLink.amount); }, 50000 ); // Increase timeout for tx confirmation -onchainTest("Gets lamport EscrowTipLink", async () => { - const connection = getConnection(); +onchainTest( + "Get lamport EscrowTipLink returns instantiated class", + async () => { + const connection = getConnection(); - if (!lamportPda) { - throw new Error( - `lamportPda must be defined to run unit test. Check 'Deposits lamport EscrowTipLink' test` - ); + if (!lamportPda) { + throw new Error( + `lamportPda must be defined to run unit test. Check 'Deposits lamport EscrowTipLink' test` + ); + } + + // Retries due to RPC inconsistency / not up to date + retryWithDelay(async () => { + const retrievedEscrowTipLink = await EscrowTipLink.get({ + connection, + pda: lamportPda, + apiKey: process.env.MAILER_API_KEY as string, + }); + + // Check object / on-chain data + expect(retrievedEscrowTipLink).toStrictEqual(lamportEscrowTipLink); + }); } - - const retrievedEscrowTipLink = await EscrowTipLink.get({ - connection, - pda: lamportPda, - apiKey: process.env.MAILER_API_KEY as string, - }); - - // Check - expect(retrievedEscrowTipLink).toStrictEqual(lamportEscrowTipLink); -}); +); onchainTest( "Withdraws lamport EscrowTipLink with depositor", @@ -150,37 +120,50 @@ onchainTest( connection, tx, [depositorKeypair], - { commitment: "confirmed" } + { + commitment: "confirmed", + } ); - const depositorEndBalance = await connection.getBalance( - depositorKeypair.publicKey - ); - - // Check on-chain data - expect(depositorEndBalance).toBeGreaterThan(depositorStartBalance); // Exact amounts are unit tested in the program repo - const retrievedEscrowTipLink = await EscrowTipLink.get({ - connection, - pda: lamportPda, - apiKey: process.env.MAILER_API_KEY as string, + // Retries due to RPC inconsistency / not up to date + await retryWithDelay(async () => { + const confTx = await connection.getTransaction(sig, { + maxSupportedTransactionVersion: 1, + }); + if (!confTx) { + throw new Error("Could not find confirmed transaction"); + } + + // Check on-chain data + const depositorEndBalance = await connection.getBalance( + depositorKeypair.publicKey, + { + minContextSlot: confTx.slot, + } + ); + expect(depositorEndBalance).toBeGreaterThan(depositorStartBalance); // Exact amounts are unit tested in the program repo }); - expect(retrievedEscrowTipLink).toBeUndefined(); - - // Check parsing - const recordedActions = await parseEscrowTx(connection, sig); - expect(recordedActions.length).toBe(1); - const recordedAction = recordedActions[0]; - expect(recordedAction.txSig).toBe(sig); - let { action } = recordedAction; - expect(action.type).toBe(EscrowActionType.WithdrawLamport); - action = action as EscrowActionWithdrawLamport; - expect(action.authority).toStrictEqual(depositorKeypair.publicKey); - expect(action.pda).toStrictEqual(lamportEscrowTipLink.pda); - expect(action.destination).toStrictEqual(depositorKeypair.publicKey); }, 50000 ); // Increase timeout for tx confirmation +onchainTest( + "Get lamport EscrowTipLink returns undefined after withdraw", + async () => { + const connection = getConnection(); + + // Retries due to RPC inconsistency / not up to date + await retryWithDelay(async () => { + const retrievedEscrowTipLink = await EscrowTipLink.get({ + connection, + pda: lamportPda, + apiKey: process.env.MAILER_API_KEY as string, + }); + expect(retrievedEscrowTipLink).toBeUndefined(); + }); + } +); + onchainTest("Creates SPL EscrowTipLink", async () => { const connection = getConnection(); const usdcMint = await getUsdcMint(); @@ -198,12 +181,16 @@ onchainTest("Creates SPL EscrowTipLink", async () => { mint: usdcMint, }); - // Check + // Check object expect(splEscrowTipLink.mint).toBe(usdcMint); expect(splEscrowTipLink.amount).toBe(amount); expect(splEscrowTipLink.toEmail).toBe(toEmail); expect(splEscrowTipLink.depositor).toBe(depositor); expect(splEscrowTipLink.receiverTipLink).toBeInstanceOf(PublicKey); + expect(splEscrowTipLink.pda).toBeDefined(); + splPda = splEscrowTipLink.pda as PublicKey; + expect(splPda).toBeInstanceOf(PublicKey); + expect(splEscrowTipLink.depositorUrl).toBeInstanceOf(URL); }); onchainTest( @@ -214,42 +201,33 @@ onchainTest( 0.1 * LAMPORTS_PER_SOL, splEscrowTipLink.amount ); + const usdcMint = await getUsdcMint(); + + const pdaAta = await getAssociatedTokenAddress( + usdcMint.address, + splPda, + true + ); + const pdaStartAmount = 0; const tx = await splEscrowTipLink.depositTx(connection); insertPrioFeesIxs(tx); - const sig = await sendAndConfirmTransaction( - connection, - tx, - [depositorKeypair], - { commitment: "confirmed" } - ); + await sendAndConfirmTransaction(connection, tx, [depositorKeypair], { + commitment: "confirmed", + }); - // Check on-chain data - expect(splEscrowTipLink.pda).toBeDefined(); - splPda = splEscrowTipLink.pda as PublicKey; - expect(splPda).toBeInstanceOf(PublicKey); - expect(splEscrowTipLink.depositorUrl).toBeInstanceOf(URL); - - // Check parsing - const recordedActions = await parseEscrowTx(connection, sig); - expect(recordedActions.length).toBe(1); - const recordedAction = recordedActions[0]; - expect(recordedAction.txSig).toBe(sig); - let { action } = recordedAction; - expect(action.type).toBe(EscrowActionType.DepositSpl); - action = action as EscrowActionDepositSpl; - expect(action.depositor).toStrictEqual(depositorKeypair.publicKey); - expect(action.pda).toStrictEqual(splEscrowTipLink.pda); - expect(action.receiverTipLink).toStrictEqual( - splEscrowTipLink.receiverTipLink - ); - expect(action.amount).toBe(splEscrowTipLink.amount); - expect(action.mint).toStrictEqual(splEscrowTipLink.mint); + // Retries due to RPC inconsistency / not up to date + await retryWithDelay(async () => { + // Check on-chain data + const pdaEndBalance = await connection.getTokenAccountBalance(pdaAta); + const pdaEndAmount = parseInt(pdaEndBalance.value.amount); + expect(pdaEndAmount - pdaStartAmount).toEqual(splEscrowTipLink.amount); + }); }, 50000 ); // Increase timeout for tx confirmation -onchainTest("Gets SPL EscrowTipLink", async () => { +onchainTest("Get SPL EscrowTipLink returns instantiated class", async () => { const connection = getConnection(); if (!splPda) { @@ -258,25 +236,29 @@ onchainTest("Gets SPL EscrowTipLink", async () => { ); } - const retrievedEscrowTipLink = await EscrowTipLink.get({ - connection, - pda: splPda, - apiKey: process.env.MAILER_API_KEY as string, - }); + // Retries due to RPC inconsistency / not up to date + await retryWithDelay(async () => { + const retrievedEscrowTipLink = await EscrowTipLink.get({ + connection, + pda: splPda, + apiKey: process.env.MAILER_API_KEY as string, + }); - // Check - expect(retrievedEscrowTipLink).toBeDefined(); - expect(retrievedEscrowTipLink?.toEmail).toBe(splEscrowTipLink.toEmail); - expect(retrievedEscrowTipLink?.depositor).toStrictEqual( - splEscrowTipLink.depositor - ); - expect(retrievedEscrowTipLink?.receiverTipLink).toStrictEqual( - splEscrowTipLink.receiverTipLink - ); - expect(retrievedEscrowTipLink?.amount).toBe(splEscrowTipLink.amount); - expect(retrievedEscrowTipLink?.mint?.address).toStrictEqual( - splEscrowTipLink.mint?.address - ); + // Check object / on-chain data + expect(retrievedEscrowTipLink).toBeDefined(); + expect(retrievedEscrowTipLink?.toEmail).toBe(splEscrowTipLink.toEmail); + expect(retrievedEscrowTipLink?.depositor).toStrictEqual( + splEscrowTipLink.depositor + ); + expect(retrievedEscrowTipLink?.receiverTipLink).toStrictEqual( + splEscrowTipLink.receiverTipLink + ); + expect(retrievedEscrowTipLink?.amount).toBe(splEscrowTipLink.amount); + expect(retrievedEscrowTipLink?.mint?.address).toStrictEqual( + splEscrowTipLink.mint?.address + ); + expect(retrievedEscrowTipLink?.pda).toStrictEqual(splPda); + }); // NOTE: We don't check depositorTa }); @@ -301,40 +283,38 @@ onchainTest( depositorKeypair.publicKey ); insertPrioFeesIxs(tx); - const sig = await sendAndConfirmTransaction( - connection, - tx, - [depositorKeypair], - { commitment: "confirmed" } - ); + await sendAndConfirmTransaction(connection, tx, [depositorKeypair], { + commitment: "confirmed", + }); - const depositorAtaEndBalance = await connection.getTokenAccountBalance( - depositorAta - ); + // Retries due to RPC inconsistency / not up to date + await retryWithDelay(async () => { + const depositorAtaEndBalance = await connection.getTokenAccountBalance( + depositorAta + ); + const depositorStartAmount = parseInt( + depositorAtaStartBalance.value.amount + ); + const depositorEndAmount = parseInt(depositorAtaEndBalance.value.amount); + // Check on-chain data + expect(depositorEndAmount - splEscrowTipLink.amount).toEqual( + depositorStartAmount + ); + }); + }, + 50000 +); // Increase timeout for tx confirmation - // Check on-chain data - expect(parseInt(depositorAtaEndBalance.value.amount)).toBeGreaterThan( - parseInt(depositorAtaStartBalance.value.amount) - ); // Exact amounts are unit tested in the program repo +onchainTest("Get SPL EscrowTipLink returns undefined", async () => { + const connection = getConnection(); + + // Retries due to RPC inconsistency / not up to date + await retryWithDelay(async () => { const retrievedEscrowTipLink = await EscrowTipLink.get({ connection, pda: splPda, apiKey: process.env.MAILER_API_KEY as string, }); expect(retrievedEscrowTipLink).toBeUndefined(); - - // Check parsing - const recordedActions = await parseEscrowTx(connection, sig); - expect(recordedActions.length).toBe(1); - const recordedAction = recordedActions[0]; - expect(recordedAction.txSig).toBe(sig); - let { action } = recordedAction; - expect(action.type).toBe(EscrowActionType.WithdrawSpl); - action = action as EscrowActionWithdrawSpl; - expect(action.authority).toStrictEqual(depositorKeypair.publicKey); - expect(action.pda).toStrictEqual(splEscrowTipLink.pda); - expect(action.destination).toStrictEqual(depositorKeypair.publicKey); - expect(action.mint).toStrictEqual(splEscrowTipLink.mint); - }, - 50000 -); // Increase timeout for tx confirmation + }); +}); diff --git a/api/test/escrow/escrow-parsing.test.ts b/api/test/escrow/escrow-parsing.test.ts index 9e773bc..9daca1e 100644 --- a/api/test/escrow/escrow-parsing.test.ts +++ b/api/test/escrow/escrow-parsing.test.ts @@ -115,6 +115,9 @@ test("Parse and serialize regular SPL desposit SPL escrow tiplink", async () => connection, serialized ); + // Manually set mint for comparison (supply could change) + const deserializedAction = deserialized[0].action as EscrowActionDepositSpl; + deserializedAction.mint = action.mint; expect(deserialized).toStrictEqual(recordedActions); }); @@ -150,6 +153,9 @@ test("Parse and serialize regular claimback SPL escrow tiplink", async () => { connection, serialized ); + // Manually set mint for comparison (supply could change) + const deserializedAction = deserialized[0].action as EscrowActionDepositSpl; + deserializedAction.mint = action.mint; expect(deserialized).toStrictEqual(recordedActions); }); @@ -238,7 +244,7 @@ test("Parse and serialize Squads deposit SPL escrow tiplink", async () => { expect(recordedActions[0].txSig).toBe(sig); expect(recordedActions[0].ixIndex).toBe(3); expect(recordedActions[0].innerIxIndex).toBe(0); - const action = recordedActions[0].action as EscrowActionDepositLamport; + const action = recordedActions[0].action as EscrowActionDepositSpl; expect(action.depositor.toBase58()).toBe( "4iud6muHzqkmef4CBeJNpFGJ3k6QZJ2o3MtXxGC718QC" ); @@ -255,5 +261,8 @@ test("Parse and serialize Squads deposit SPL escrow tiplink", async () => { connection, serialized ); + // Manually set mint for comparison (supply could change) + const deserializedAction = deserialized[0].action as EscrowActionDepositSpl; + deserializedAction.mint = action.mint; expect(deserialized).toStrictEqual(recordedActions); }); diff --git a/api/test/escrow/helpers.ts b/api/test/escrow/helpers.ts index 268f4bf..f69100e 100644 --- a/api/test/escrow/helpers.ts +++ b/api/test/escrow/helpers.ts @@ -10,10 +10,13 @@ import { ComputeBudgetProgram, Transaction, LAMPORTS_PER_SOL, + VersionedTransactionResponse, } from "@solana/web3.js"; import { getMint, Mint } from "@solana/spl-token"; import { getAssociatedTokenAddress } from "@solana/spl-token"; +import { sleep } from "../../src/helpers"; + let connection: Connection; export function getConnection() { if (connection) { @@ -156,3 +159,28 @@ export function insertPrioFeesIxs( const ixs = getPrioFeesIxs(cuPrice, cuLimit); tx.instructions.unshift(...ixs); } + +export async function retryWithDelay( + asyncFn: () => Promise, + maxRetries = 5, + delay = 1000 +): Promise { + if (maxRetries < 0) { + throw new Error("Max retries should be 0 or greater"); + } + + let curRetries = 0; + while (curRetries <= maxRetries) { + try { + return await asyncFn(); + } catch (err) { + if (curRetries >= maxRetries) { + throw err; + } + curRetries++; + await sleep(delay); + } + } + + throw new Error("Code should not reach this point"); +} diff --git a/mailer/.env.example b/mailer/.env.example index 17ea3ff..9300232 100644 --- a/mailer/.env.example +++ b/mailer/.env.example @@ -7,4 +7,4 @@ MAILER_API_KEY= NEXT_PUBLIC_ESCROW_DEPOSITOR_URL_OVERRIDE= # Ex. "https://localhost:3001/depositor-url" NEXT_PUBLIC_ESCROW_RECEIVER_URL_OVERRIDE= # Ex. "http://localhost:3000/e" NEXT_PUBLIC_ENCLAVE_ENDPOINT_OVERRIDE= -NEXT_PUBLIC_BACKEND_ENDPOINT_OVERRIDE= +NEXT_PUBLIC_INDEXER_URL_OVERRIDE= diff --git a/mailer/package-lock.json b/mailer/package-lock.json index de239a0..a83703f 100644 --- a/mailer/package-lock.json +++ b/mailer/package-lock.json @@ -16,6 +16,7 @@ "@solana/wallet-adapter-wallets": "^0.19.23", "@solana/web3.js": "^1.87.6", "@tiplink/api": "file:../api", + "@tiplink/util": "file:../../tiplink-util", "@tiplink/wallet-adapter": "^2.1.9", "@tiplink/wallet-adapter-react-ui": "^0.1.10", "concurrently": "^8.2.2", @@ -46,9 +47,23 @@ "typescript": "^5" } }, + "../../tiplink-util": { + "version": "0.1.0", + "license": "ISC", + "dependencies": { + "@solana/web3.js": "^1.48.0", + "typescript": "^4.7.4" + }, + "devDependencies": { + "@typescript-eslint/eslint-plugin": "^5.30.6", + "@typescript-eslint/parser": "^5.30.6", + "@typescript-eslint/typescript-estree": "^6.18.1", + "eslint": "^8.19.0" + } + }, "../api": { "name": "@tiplink/api", - "version": "0.2.5", + "version": "0.3.0", "license": "LicenseRef-LICENSE", "dependencies": { "@coral-xyz/anchor": "^0.29.0", @@ -8323,6 +8338,10 @@ "resolved": "../api", "link": true }, + "node_modules/@tiplink/util": { + "resolved": "../../tiplink-util", + "link": true + }, "node_modules/@tiplink/wallet-adapter": { "version": "2.1.9", "resolved": "https://registry.npmjs.org/@tiplink/wallet-adapter/-/wallet-adapter-2.1.9.tgz", diff --git a/mailer/package.json b/mailer/package.json index 2544e83..a25369d 100644 --- a/mailer/package.json +++ b/mailer/package.json @@ -3,14 +3,17 @@ "version": "0.1.0", "private": true, "scripts": { - "preinstall": "npm run install:tiplink-api && npm run build:tiplink-api", - "dev": "concurrently \"npm run dev:tiplink-api\" \"next dev --experimental-https\"", + "preinstall": "npm run install:tiplink-util && npm run build:tiplink-util && npm run install:tiplink-api && npm run build:tiplink-api", + "dev": "concurrently \"npm run dev:tiplink-util\" \"npm run dev:tiplink-api\" \"next dev --experimental-https\"", "build": "npm run build:tiplink-api && next build", "start": "next start", "lint": "next lint", "install:tiplink-api": "cd ../api && npm install", + "install:tiplink-util": "cd ../../tiplink-util && npm install", "dev:tiplink-api": "cd ../api && npm run dev", - "build:tiplink-api": "cd ../api && npm run build" + "dev:tiplink-util": "cd ../../tiplink-util && npm run dev", + "build:tiplink-api": "cd ../api && npm run build", + "build:tiplink-util": "cd ../../tiplink-util && npm run build" }, "dependencies": { "@coral-xyz/anchor": "^0.29.0", @@ -20,6 +23,7 @@ "@solana/wallet-adapter-wallets": "^0.19.23", "@solana/web3.js": "^1.87.6", "@tiplink/api": "file:../api", + "@tiplink/util": "file:../../tiplink-util", "@tiplink/wallet-adapter": "^2.1.9", "@tiplink/wallet-adapter-react-ui": "^0.1.10", "concurrently": "^8.2.2", diff --git a/mailer/src/app/actions.ts b/mailer/src/app/actions.ts index c066ade..fb12b28 100644 --- a/mailer/src/app/actions.ts +++ b/mailer/src/app/actions.ts @@ -80,6 +80,7 @@ export async function mailAction( toName?: string, replyEmail?: string, replyName?: string, + templateName?: string, ): Promise { const tipLink = await TipLink.fromLink(tipLinkUrl); @@ -90,6 +91,7 @@ export async function mailAction( toName, replyEmail, replyName, + templateName, ); } @@ -100,6 +102,7 @@ export async function mailEscrowAction( toName?: string, replyEmail?: string, replyName?: string, + templateName?: string, ): Promise { const receiverTipLink = new PublicKey(receiverTipLinkPublicKey); @@ -111,5 +114,6 @@ export async function mailEscrowAction( toName, replyEmail, replyName, + templateName, }); } diff --git a/mailer/src/app/page.tsx b/mailer/src/app/page.tsx index e7beff8..598efa1 100644 --- a/mailer/src/app/page.tsx +++ b/mailer/src/app/page.tsx @@ -55,6 +55,7 @@ export default function Home(): JSX.Element { const [isFailure, setIsFailure] = useState(false); const [statusUrl, setStatusUrl] = useState(""); const [statusLabel, setStatusLabel] = useState(""); + const [templateName, setTemplateName] = useState(""); const sendTipLink = useCallback(async (): Promise => { if (!publicKey) { @@ -94,10 +95,20 @@ export default function Home(): JSX.Element { toName !== "" ? toName : undefined, replyEmail !== "" ? replyEmail : undefined, replyName !== "" ? replyName : undefined, + templateName !== "" ? templateName : undefined, ); return sig; - }, [amount, replyEmail, replyName, publicKey, toEmail, toName, sendWalletTx]); + }, [ + amount, + replyEmail, + replyName, + publicKey, + toEmail, + toName, + sendWalletTx, + templateName, + ]); const sendSplTipLink = useCallback( async (mintAddr: PublicKey): Promise => { @@ -169,6 +180,7 @@ export default function Home(): JSX.Element { toName !== "" ? toName : undefined, replyEmail !== "" ? replyEmail : undefined, replyName !== "" ? replyName : undefined, + templateName !== "" ? templateName : undefined, ); return sig; @@ -182,6 +194,7 @@ export default function Home(): JSX.Element { toEmail, toName, sendWalletTx, + templateName, ], ); @@ -218,6 +231,7 @@ export default function Home(): JSX.Element { toName !== "" ? toName : undefined, replyEmail !== "" ? replyEmail : undefined, replyName !== "" ? replyName : undefined, + templateName !== "" ? templateName : undefined, ); return sig; @@ -230,6 +244,7 @@ export default function Home(): JSX.Element { replyName, sendWalletTx, connection, + templateName, ]); const sendEscrowSplTipLink = useCallback( @@ -270,6 +285,7 @@ export default function Home(): JSX.Element { toName !== "" ? toName : undefined, replyEmail !== "" ? replyEmail : undefined, replyName !== "" ? replyName : undefined, + templateName !== "" ? templateName : undefined, ); return sig; @@ -283,6 +299,7 @@ export default function Home(): JSX.Element { replyEmail, replyName, sendWalletTx, + templateName, ], ); @@ -519,6 +536,24 @@ export default function Home(): JSX.Element { /> +
+
+ +
+
+ setTemplateName(e.target.value)} + /> +
+