UNPKG

@daimo/pay

Version:

Seamless crypto payments. Onboard users from any chain, any coin into your app with one click.

296 lines (293 loc) 11.5 kB
import { assert, readDaimoPayOrderID, getOrderDestChainId, DaimoPayOrderMode } from '@daimo/pay-common'; import { formatUnits, getAddress } from 'viem'; import { startPolling } from '../utils/polling.js'; // Maps poller identifier to poll handle which terminates the poller // key = `${type}:${orderId}` const pollers = new Map(); var PollerType; (function (PollerType) { PollerType["FIND_SOURCE_PAYMENT"] = "find_source_payment"; PollerType["REFRESH_ORDER"] = "refresh_order"; })(PollerType || (PollerType = {})); function stopPoller(key) { pollers.get(key)?.(); pollers.delete(key); } /** * Add a subscriber to the payment store that runs side effects in response to * events. * * @param store The payment store to subscribe to. * @param trpc TRPC client pointing to the Daimo Pay API. * @param log The logger to use for logging. * @returns A function that can be used to unsubscribe from the store. */ function attachPaymentEffectHandlers(store, trpc, log) { const unsubscribe = store.subscribe(({ prev, next, event }) => { log(`[EFFECT] processing effects for event ${event.type} on state transition ${prev.type} -> ${next.type}`); /* -------------------------------------------------- * State-driven effects * -------------------------------------------------- */ if (prev.type !== next.type) { // Start watching for source payment if (next.type === "payment_unpaid") { pollFindPayments(store, trpc, next.order.id); } // Refresh the order to watch for destination processing if (next.type === "payment_started") { pollRefreshOrder(store, trpc, next.order.id); } // Stop all pollers when the payment flow is completed or reset if (["payment_completed", "payment_bounced", "error", "idle"].includes(next.type)) { if ("order" in prev && prev.order) { stopPoller(`${PollerType.FIND_SOURCE_PAYMENT}:${prev.order.id}`); stopPoller(`${PollerType.REFRESH_ORDER}:${prev.order.id}`); } } } /* -------------------------------------------------- * Event-driven effects * -------------------------------------------------- */ switch (event.type) { case "set_pay_params": runSetPayParamsEffects(store, trpc, event); break; case "set_pay_id": runSetPayIdEffects(store, trpc, event); break; case "hydrate_order": { if (prev.type === "preview") { runHydratePayParamsEffects(store, trpc, prev, event); } else if (prev.type === "unhydrated") { runHydratePayIdEffects(store, trpc, prev, event); } else { log(`[EFFECT] invalid event ${event.type} on state ${prev.type}`); } break; } case "pay_source": { if (prev.type === "payment_unpaid") { runPaySourceEffects(store, trpc, prev); } else { log(`[EFFECT] invalid event ${event.type} on state ${prev.type}`); } break; } case "pay_ethereum_source": { if (prev.type === "payment_unpaid") { runPayEthereumSourceEffects(store, trpc, prev, event); } else { log(`[EFFECT] invalid event ${event.type} on state ${prev.type}`); } break; } case "pay_solana_source": { if (prev.type === "payment_unpaid") { runPaySolanaSourceEffects(store, trpc, prev, event); } log(`[EFFECT] invalid event ${event.type} on state ${prev.type}`); break; } } }); const cleanup = () => { unsubscribe(); pollers.forEach((_, key) => stopPoller(key)); log("[EFFECT] unsubscribed from payment store and stopped all pollers"); }; return cleanup; } async function pollFindPayments(store, trpc, orderId) { const key = `${PollerType.FIND_SOURCE_PAYMENT}:${orderId}`; const stopPolling = startPolling({ key, intervalMs: 1_000, pollFn: () => trpc.findOrderPayments.query({ orderId: orderId.toString() }), onResult: (order) => { const state = store.getState(); if (state.type !== "payment_unpaid") { stopPolling(); return; } store.dispatch({ type: "order_refreshed", order }); }, onError: () => { }, }); pollers.set(key, stopPolling); } async function pollRefreshOrder(store, trpc, orderId) { const key = `${PollerType.REFRESH_ORDER}:${orderId}`; const stopPolling = startPolling({ key, intervalMs: 300, pollFn: () => trpc.getOrder.query({ id: orderId.toString() }), onResult: (res) => { const state = store.getState(); // Check that we're still in the payment_started state if (state.type !== "payment_started") { stopPolling(); return; } const order = res.order; store.dispatch({ type: "order_refreshed", order }); }, onError: () => { }, }); pollers.set(key, stopPolling); } async function runSetPayParamsEffects(store, trpc, event) { const payParams = event.payParams; // toUnits is undefined if and only if we're in deposit flow. // Set dummy value for deposit flow, since user can edit the amount. const toUnits = payParams.toUnits == null ? "0" : payParams.toUnits; // Validate payParams. assert(payParams.appId.length > 0, "PayParams: appId required"); const isDepositFlow = payParams.toUnits == null; assert(!isDepositFlow || payParams.externalId == null, "PayParams: externalId unsupported in deposit mode"); try { const orderPreview = await trpc.previewOrder.query({ appId: payParams.appId, toChain: payParams.toChain, toToken: payParams.toToken, toUnits, toAddress: payParams.toAddress, toCallData: payParams.toCallData, isAmountEditable: payParams.toUnits == null, metadata: { intent: payParams.intent ?? "Pay", items: [], payer: { paymentOptions: payParams.paymentOptions, preferredChains: payParams.preferredChains, preferredTokens: payParams.preferredTokens, evmChains: payParams.evmChains, }, }, externalId: payParams.externalId, userMetadata: payParams.metadata, refundAddress: payParams.refundAddress, }); store.dispatch({ type: "preview_generated", // TODO: Properly type this and fix hacky type casting order: orderPreview, payParamsData: { appId: payParams.appId, }, }); } catch (e) { store.dispatch({ type: "error", order: undefined, message: e.message }); } } async function runSetPayIdEffects(store, trpc, event) { try { const { order } = await trpc.getOrder.query({ id: readDaimoPayOrderID(event.payId).toString(), }); store.dispatch({ type: "order_loaded", order, }); } catch (e) { store.dispatch({ type: "error", order: undefined, message: e.message }); } } async function runHydratePayParamsEffects(store, trpc, prev, event) { const order = prev.order; const toUnits = formatUnits(BigInt(order.destFinalCallTokenAmount.amount), order.destFinalCallTokenAmount.token.decimals); try { const { hydratedOrder } = await trpc.createOrder.mutate({ appId: prev.payParamsData.appId, paymentInput: { id: order.id.toString(), toChain: getOrderDestChainId(order), toToken: getAddress(order.destFinalCallTokenAmount.token.token), toUnits, toAddress: getAddress(order.destFinalCall.to), toCallData: order.destFinalCall.data, isAmountEditable: order.mode === DaimoPayOrderMode.CHOOSE_AMOUNT, metadata: order.metadata, userMetadata: order.userMetadata, externalId: order.externalId ?? undefined, }, // Prefer the refund address passed to this function, if specified. This // is for cases where the user pays from an EOA. Otherwise, use the refund // address specified by the dev. refundAddress: event.refundAddress ?? prev.order.refundAddr ?? undefined, }); store.dispatch({ type: "order_hydrated", order: hydratedOrder, }); } catch (e) { store.dispatch({ type: "error", order: prev.order, message: e.message }); } } async function runHydratePayIdEffects(store, trpc, prev, event) { const order = prev.order; try { const { hydratedOrder } = await trpc.hydrateOrder.query({ id: order.id.toString(), refundAddress: event.refundAddress, }); store.dispatch({ type: "order_hydrated", order: hydratedOrder, }); } catch (e) { store.dispatch({ type: "error", order: prev.order, message: e.message }); } } async function runPaySourceEffects(store, trpc, prev) { const orderId = prev.order.id; try { const order = await trpc.findOrderPayments.query({ orderId: orderId.toString(), }); store.dispatch({ type: "order_refreshed", order }); } catch (e) { store.dispatch({ type: "error", order: prev.order, message: e.message }); } } async function runPayEthereumSourceEffects(store, trpc, prev, event) { const orderId = prev.order.id; try { const order = await trpc.processSourcePayment.mutate({ orderId: orderId.toString(), sourceInitiateTxHash: event.paymentTxHash, sourceChainId: event.sourceChainId, sourceFulfillerAddr: event.payerAddress, sourceToken: event.sourceToken, sourceAmount: event.sourceAmount.toString(), }); store.dispatch({ type: "payment_verified", order }); } catch (e) { store.dispatch({ type: "error", order: prev.order, message: e.message }); } } async function runPaySolanaSourceEffects(store, trpc, prev, event) { const orderId = prev.order.id; try { const order = await trpc.processSolanaSourcePayment.mutate({ orderId: orderId.toString(), startIntentTxHash: event.paymentTxHash, token: event.sourceToken, }); store.dispatch({ type: "payment_verified", order }); } catch (e) { store.dispatch({ type: "error", order: prev.order, message: e.message }); } } export { attachPaymentEffectHandlers }; //# sourceMappingURL=paymentEffects.js.map