Skip to content
Open
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
289 changes: 228 additions & 61 deletions apps/telegram-ecash-escrow/src/app/order-detail/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import MobileLayout from '@/src/components/layout/MobileLayout';
import QRCode from '@/src/components/QRcode/QRcode';
import TelegramButton from '@/src/components/TelegramButton/TelegramButton';
import TickerHeader from '@/src/components/TickerHeader/TickerHeader';
import { securityDepositPercentage } from '@/src/store/constants';
import { OfferCategory, securityDepositPercentage } from '@/src/store/constants';
import { SettingContext } from '@/src/store/context/settingProvider';
import { UtxoContext } from '@/src/store/context/utxoProvider';
import { buildReleaseTx, buildReturnFeeTx, buildReturnTx, sellerBuildDepositTx } from '@/src/store/escrow';
Expand All @@ -30,7 +30,7 @@ import {
hexToUint8Array,
showPriceInfo
} from '@/src/store/util';
import { COIN, coinInfo } from '@bcpros/lixi-models';
import { COIN, coinInfo, PAYMENT_METHOD } from '@bcpros/lixi-models';
import {
DisputeStatus,
EscrowOrderAction,
Expand Down Expand Up @@ -150,6 +150,52 @@ const OrderDetail = () => {

const isBuyOffer = currentData?.escrowOrder?.escrowOffer?.type === OfferType.Buy;

/**
* Determines if this order uses external payment flow (seller escrows collateral)
* vs direct payment flow (buyer deposits directly).
*
* PAYMENT FLOW TYPES:
* 1. EXTERNAL PAYMENT (seller escrows collateral):
* - Legacy G&S offers (paymentMethodId = 5): Seller escrows XEC as collateral
* - G&S category + Bank Transfer (paymentMethodId = 2): Buyer pays externally
* - G&S category + Payment App (paymentMethodId = 3): Buyer pays via app
* - G&S category + Crypto non-XEC (paymentMethodId = 4, coinPayment != 'XEC'): Buyer pays with other crypto
* UI: Shows "Seller Collateral Escrowed" and "Confirm Receipt" button for buyer
* Buyer action: Confirm receipt to release collateral
*
* 2. DIRECT PAYMENT (buyer deposits XEC):
* - G&S category + Crypto XEC (paymentMethodId = 4, coinPayment = 'XEC'): Direct XEC payment
* UI: Shows standard order details without external payment messaging
* Buyer action: Uses standard release/return flows
*/
const isExternalPaymentOrder = useMemo(() => {
const hasGoodsServicesCategory =
(currentData?.escrowOrder?.escrowOffer as { offerCategory?: string })?.offerCategory ===
OfferCategory.GOODS_SERVICES;
const paymentMethodId = currentData?.escrowOrder?.paymentMethod?.id;
// Default missing coinPayment to 'XEC' to match behavior elsewhere
// This ensures G&S + CRYPTO with no coinPayment is treated as direct XEC payment (not external)
const coinPayment = (currentData?.escrowOrder?.escrowOffer?.coinPayment || 'XEC').toUpperCase();

// Case 1: Legacy G&S offers (paymentMethodId = 5) are treated as external payment
if (paymentMethodId === PAYMENT_METHOD.GOODS_SERVICES) {
return true;
}

// Case 2: Not a G&S category offer = not external payment (standard XEC trading)
if (!hasGoodsServicesCategory) {
return false;
}

// Case 3: G&S category with Crypto (XEC) = direct XEC payment, NOT external
if (paymentMethodId === PAYMENT_METHOD.CRYPTO && coinPayment === 'XEC') {
return false;
}

// Case 4: All other G&S category offers = external payment
return true;
}, [currentData?.escrowOrder]);

useEffect(() => {
if (
currentData?.escrowOrder.escrowOrderStatus !== EscrowOrderStatus.Complete &&
Expand Down Expand Up @@ -385,6 +431,57 @@ const OrderDetail = () => {
setLoading(false);
};

/**
* Handler for buyer to confirm receipt in external payment orders
* This releases the seller's collateral back to the seller
*/
const handleBuyerConfirmReceipt = async () => {
setLoading(true);

if (currentData?.escrowOrder.escrowOrderStatus === EscrowOrderStatus.Complete) {
dispatch(
showToast('warning', {
message: 'warning',
description: 'Order has already been completed!'
})
);
setLoading(false);
return;
}

try {
const buyerSk = fromHex(selectedWalletPath?.privateKey);
const buyerPk = fromHex(selectedWalletPath.publicKey as string);
const buyerPkh = shaRmd160(buyerPk);
const nonce = currentData?.escrowOrder.nonce as string;
// Use BUYER_RETURN action to sign (XEC goes back to seller)
const buyerSignatory = SignOracleSignatory(buyerSk, ACTION.BUYER_RETURN, nonce);

await updateEscrowOrderSignatoryTrigger({
input: {
orderId: id!,
// Note: EscrowOrderAction.BuyerConfirmReceipt is the correct enum value
// Using string literal here for compatibility until types are regenerated
action: 'BUYER_CONFIRM_RECEIPT' as unknown as EscrowOrderAction,
signatory: hexEncode(buyerSignatory),
signatoryOwnerHash160: hexEncode(buyerPkh)
}
}).unwrap();

dispatch(
showToast('success', {
message: 'success',
description: 'Receipt confirmed! Seller collateral released.'
})
);
} catch (e) {
console.log(e);
showError();
}

setLoading(false);
};

const handleBuyerClaimEscrow = async () => {
setLoading(true);
try {
Expand Down Expand Up @@ -1157,74 +1254,144 @@ const OrderDetail = () => {

// Default escrow state
if (isSeller) {
state.statusComponent = (
<Typography variant="body1" color="error" align="center">
Only release the escrowed funds once you have confirmed that the buyer has completed the payment or
goods/services.
</Typography>
);
state.actionButtons = (
<div className="group-button-wrap">
<Button color="warning" variant="contained" disabled={loading} onClick={() => handleCreateDispute()}>
Dispute
</Button>
<Button color="success" variant="contained" onClick={() => setOpenReleaseModal(true)} disabled={loading}>
Release
</Button>
</div>
);
} else {
state.statusColor = '#66bb6a';
state.statusComponent = (
<React.Fragment>
<Typography variant="body1" color="#66bb6a" align="center">
Successfully Escrowed!
// For external payment, seller sees different message (they deposited as collateral)
if (isExternalPaymentOrder) {
state.statusComponent = (
<Typography variant="body1" color="warning.main" align="center">
Your XEC collateral is held in escrow. The buyer will confirm receipt after you deliver the
goods/services. Your collateral will be released back to you upon confirmation.
</Typography>
<Stack
direction="row"
spacing={0}
justifyContent="center"
color="white"
alignItems="center"
margin="20px"
>
<Image width={50} height={50} src="/safebox-close.svg" alt="" />
<CheckIcon color="success" style={{ fontSize: '50px' }} />
</Stack>
<Typography variant="body1" color="#66bb6a" align="center">
{`${currentData.escrowOrder.amount} XEC has been safely locked. You are now safe to send payments or goods to settle the order.`}
);
state.actionButtons = (
<div className="group-button-wrap">
<Button color="warning" variant="contained" disabled={loading} onClick={() => handleCreateDispute()}>
Dispute
</Button>
</div>
);
} else {
state.statusComponent = (
<Typography variant="body1" color="error" align="center">
Only release the escrowed funds once you have confirmed that the buyer has completed the payment or
goods/services.
</Typography>
</React.Fragment>
);
state.actionButtons = (
<div>
{telegramButton('Chat with seller for payment details')}
);
state.actionButtons = (
<div className="group-button-wrap">
{currentData.escrowOrder?.markAsPaid ? (
<Button
color="warning"
variant="contained"
disabled={loading || isDisabled}
onClick={() => handleCreateDispute()}
>
Dispute
</Button>
) : (
<Button color="success" variant="contained" disabled={loading} onClick={() => handleMarkAsPaid()}>
Mark as paid
</Button>
)}
<Button color="warning" variant="contained" disabled={loading} onClick={() => handleCreateDispute()}>
Dispute
</Button>
<Button
style={{ backgroundColor: '#a41208' }}
color="success"
variant="contained"
onClick={() => setOpenCancelModal(true)}
onClick={() => setOpenReleaseModal(true)}
disabled={loading}
>
Cancel
Release
</Button>
</div>
</div>
);
);
}
} else {
state.statusColor = '#66bb6a';

// For external payment, buyer sees different message and actions
if (isExternalPaymentOrder) {
state.statusComponent = (
<React.Fragment>
<Typography variant="body1" color="#66bb6a" align="center">
Seller Collateral Escrowed!
</Typography>
<Stack
direction="row"
spacing={0}
justifyContent="center"
color="white"
alignItems="center"
margin="20px"
>
<Image width={50} height={50} src="/safebox-close.svg" alt="" />
<CheckIcon color="success" style={{ fontSize: '50px' }} />
</Stack>
<Typography variant="body1" color="#66bb6a" align="center">
{`${currentData.escrowOrder.amount} XEC has been locked as seller's collateral.`}
</Typography>
<Typography variant="body2" color="text.secondary" align="center" sx={{ mt: 1 }}>
Pay the seller externally for the goods/services. Once you receive them, click "Confirm Receipt" to
release the collateral back to the seller.
</Typography>
</React.Fragment>
);
state.actionButtons = (
<div>
{telegramButton('Chat with seller for payment details')}
<div className="group-button-wrap">
<Button color="warning" variant="contained" disabled={loading} onClick={() => handleCreateDispute()}>
Dispute
</Button>
<Button
color="success"
variant="contained"
disabled={loading}
onClick={() => handleBuyerConfirmReceipt()}
>
Confirm Receipt
</Button>
</div>
</div>
);
} else {
state.statusComponent = (
<React.Fragment>
<Typography variant="body1" color="#66bb6a" align="center">
Successfully Escrowed!
</Typography>
<Stack
direction="row"
spacing={0}
justifyContent="center"
color="white"
alignItems="center"
margin="20px"
>
<Image width={50} height={50} src="/safebox-close.svg" alt="" />
<CheckIcon color="success" style={{ fontSize: '50px' }} />
</Stack>
<Typography variant="body1" color="#66bb6a" align="center">
{`${currentData.escrowOrder.amount} XEC has been safely locked. You are now safe to send payments or goods to settle the order.`}
</Typography>
</React.Fragment>
);
state.actionButtons = (
<div>
{telegramButton('Chat with seller for payment details')}
<div className="group-button-wrap">
{currentData.escrowOrder?.markAsPaid ? (
<Button
color="warning"
variant="contained"
disabled={loading || isDisabled}
onClick={() => handleCreateDispute()}
>
Dispute
</Button>
) : (
<Button color="success" variant="contained" disabled={loading} onClick={() => handleMarkAsPaid()}>
Mark as paid
</Button>
)}
<Button
style={{ backgroundColor: '#a41208' }}
variant="contained"
onClick={() => setOpenCancelModal(true)}
disabled={loading}
>
Cancel
</Button>
</div>
</div>
);
}
}
break;

Expand Down
21 changes: 19 additions & 2 deletions apps/telegram-ecash-escrow/src/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import FiatRateErrorBanner from '@/src/components/Common/FiatRateErrorBanner';
import Header from '@/src/components/Header/Header';
import OfferItem from '@/src/components/OfferItem/OfferItem';
import { PAYMENT_METHOD } from '@bcpros/lixi-models';
import {
OfferOrderField,
OrderDirection,
Expand Down Expand Up @@ -86,6 +87,22 @@ export default function Home() {
const [visible, setVisible] = useState(true);
const dispatch = useLixiSliceDispatch();

// Filter out Goods & Services offers from P2P trading view
// Default to showing Buy offers (isBuyOffer: true means showing offers where users want to buy XEC)
const tradingFilterConfig = useMemo(
() => ({
...offerFilterConfig,
// Default to Buy offers if not specified (users typically want to buy XEC)
isBuyOffer: offerFilterConfig.isBuyOffer ?? true,
// Exclude Goods & Services payment method (ID = 5)
paymentMethodIds:
offerFilterConfig.paymentMethodIds && offerFilterConfig.paymentMethodIds.length > 0
? offerFilterConfig.paymentMethodIds.filter(id => id !== PAYMENT_METHOD.GOODS_SERVICES)
: [1, 2, 3, 4] // Default: Bank Transfer, Payment App, Crypto, Cash In Person (exclude Goods/Services)
}),
[offerFilterConfig]
);

// Prefetch fiat rates in the background for better modal performance
// This will cache the data so PlaceAnOrderModal can use it immediately
const {
Expand All @@ -97,7 +114,7 @@ export default function Home() {
refetchOnMountOrArgChange: true
});

const isShowSortIcon = isShowAmountOrSortFilter(offerFilterConfig);
const isShowSortIcon = isShowAmountOrSortFilter(tradingFilterConfig);

const {
data: dataFilter,
Expand All @@ -106,7 +123,7 @@ export default function Home() {
fetchNext: fetchNextFilter,
isLoading: isLoadingFilter,
refetch
} = useInfiniteOfferFilterDatabaseQuery({ first: 20, offerFilterInput: offerFilterConfig }, false);
} = useInfiniteOfferFilterDatabaseQuery({ first: 20, offerFilterInput: tradingFilterConfig }, false);

const loadMoreItemsFilter = () => {
if (hasNextFilter && !isFetchingFilter) {
Expand Down
Loading
Loading