@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
JavaScript
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