Skip to content

Commit a42d60e

Browse files
committed
feat: ZEUS Cashu gift portal links
1 parent 48d08cc commit a42d60e

File tree

9 files changed

+274
-8
lines changed

9 files changed

+274
-8
lines changed

components/CollapsedQR.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,7 @@ interface CollapsedQRProps {
128128
labelBottom?: string;
129129
qrAnimationSpeed?: QRAnimationSpeed;
130130
onQRAnimationSpeedChange?: (speed: QRAnimationSpeed) => void;
131+
onShareGiftLink?: () => void;
131132
}
132133

133134
interface CollapsedQRState {
@@ -185,7 +186,8 @@ export default class CollapsedQR extends React.Component<
185186
labelBottom,
186187
qrAnimationSpeed,
187188
onQRAnimationSpeedChange,
188-
showSpeed
189+
showSpeed,
190+
onShareGiftLink
189191
} = this.props;
190192

191193
const { width, height } = Dimensions.get('window');
@@ -446,6 +448,7 @@ export default class CollapsedQR extends React.Component<
446448
onShareComplete={() =>
447449
this.setState({ tempQRRef: null })
448450
}
451+
onShareGiftLink={onShareGiftLink}
449452
/>
450453
{supportsNFC && (
451454
<NFCButton

components/Modals/ShareModal.tsx

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { themeColor } from '../../utils/ThemeUtils';
1313

1414
import QR from '../../assets/images/SVG/QR.svg';
1515
import TextSVG from '../../assets/images/SVG/Text.svg';
16+
import Gift from '../../assets/images/SVG/gift.svg';
1617

1718
interface ShareModalProps {
1819
ModalStore: ModalStore;
@@ -28,14 +29,17 @@ export default class ShareModal extends React.Component<ShareModalProps, {}> {
2829
toggleShareModal,
2930
shareQR,
3031
shareText,
32+
shareGiftLink,
33+
onShareGiftLink,
3134
closeVisibleModalDialog
3235
} = ModalStore;
3336

3437
return (
3538
<ModalBox
3639
style={{
3740
...styles.modal,
38-
backgroundColor: themeColor('background')
41+
backgroundColor: themeColor('background'),
42+
height: onShareGiftLink ? 330 : 250
3943
}}
4044
swipeToClose={true}
4145
backButtonClose={true}
@@ -121,6 +125,40 @@ export default class ShareModal extends React.Component<ShareModalProps, {}> {
121125
</Text>
122126
</Row>
123127
</TouchableOpacity>
128+
{onShareGiftLink && (
129+
<TouchableOpacity
130+
key="share-gift-link"
131+
onPress={async () => {
132+
shareGiftLink();
133+
closeVisibleModalDialog();
134+
}}
135+
style={{
136+
...styles.sendOption,
137+
backgroundColor: themeColor('secondary')
138+
}}
139+
>
140+
<Row>
141+
<View style={{ marginRight: 15 }}>
142+
<Gift
143+
fill={
144+
themeColor('action') ||
145+
themeColor('highlight')
146+
}
147+
width={28}
148+
height={28}
149+
/>
150+
</View>
151+
<Text
152+
style={{
153+
...styles.sendOptionLabel,
154+
color: themeColor('text')
155+
}}
156+
>
157+
{localeString('general.giftLink')}
158+
</Text>
159+
</Row>
160+
</TouchableOpacity>
161+
)}
124162
</View>
125163
</ModalBox>
126164
);

components/ShareButton.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,16 +25,18 @@ interface ShareButtonProps {
2525
iconOnly?: boolean;
2626
onPress: () => Promise<void>;
2727
onShareComplete?: () => void;
28+
onShareGiftLink?: () => void;
2829
iconContainerStyle?: ViewStyle;
2930
}
3031

3132
export default class ShareButton extends React.Component<ShareButtonProps> {
3233
handlePress = async () => {
33-
const { onPress } = this.props;
34+
const { onPress, onShareGiftLink } = this.props;
3435
await onPress();
3536
modalStore.toggleShareModal({
3637
onShareQR: this.shareQR,
37-
onShareText: this.shareText
38+
onShareText: this.shareText,
39+
onShareGiftLink
3840
});
3941
};
4042

locales/en.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,7 @@
172172
"general.encrypt": "Encrypt",
173173
"general.qr": "QR",
174174
"general.text": "Text",
175+
"general.giftLink": "Gift link",
175176
"general.signature": "Signature",
176177
"general.ignore": "Ignore",
177178
"general.once": "Once",

stores/ModalStore.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ export default class ModalStore {
2626
@observable public alertModalNav: string | undefined;
2727
@observable public onShareQR?: () => void;
2828
@observable public onShareText?: () => void;
29+
@observable public onShareGiftLink?: () => void;
2930
@observable public onPress: () => void;
3031

3132
/* External Link Modal */
@@ -61,14 +62,17 @@ export default class ModalStore {
6162
@action
6263
public toggleShareModal = ({
6364
onShareQR,
64-
onShareText
65+
onShareText,
66+
onShareGiftLink
6567
}: {
6668
onShareQR?: () => void;
6769
onShareText?: () => void;
70+
onShareGiftLink?: () => void;
6871
}) => {
6972
this.showShareModal = onShareQR && onShareText ? true : false;
7073
this.onShareQR = onShareQR;
7174
this.onShareText = onShareText;
75+
this.onShareGiftLink = onShareGiftLink;
7276
};
7377

7478
@action
@@ -99,6 +103,9 @@ export default class ModalStore {
99103
public shareText = () => {
100104
if (this.onShareText) this.onShareText();
101105
};
106+
public shareGiftLink = () => {
107+
if (this.onShareGiftLink) this.onShareGiftLink();
108+
};
102109

103110
@action
104111
public setUrl = (text: string) => {
@@ -143,6 +150,7 @@ export default class ModalStore {
143150
this.showShareModal = false;
144151
this.onShareQR = undefined;
145152
this.onShareText = undefined;
153+
this.onShareGiftLink = undefined;
146154
return true;
147155
}
148156
if (this.showNewChannelModal) {

utils/AddressUtils.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,9 @@ const lightningAddress =
4949

5050
const blueWalletAddress = /^bluewallet:setlndhuburl\?url=(\S+)/;
5151

52+
/* ZEUS ecash gift URL */
53+
const ZEUS_ECASH_GIFT_URL = 'https://zeusln.com/e/';
54+
5255
/* npub */
5356
const npubFormat = /^npub1[0-9a-z]{58}$/;
5457

@@ -502,3 +505,4 @@ class AddressUtils {
502505

503506
const addressUtils = new AddressUtils();
504507
export default addressUtils;
508+
export { ZEUS_ECASH_GIFT_URL };

utils/handleAnything.test.ts

Lines changed: 179 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ let mockSupportsOnchainSends = true;
2626
let mockGetLnurlParams = {};
2727
let mockBlobUtilFetch = jest.fn();
2828

29+
const ZEUS_ECASH_GIFT_URL = 'https://zeusln.com/e/';
2930
jest.mock('./AddressUtils', () => ({
3031
processBIP21Uri: (...args: string[]) => mockProcessBIP21Uri(...args),
3132
isValidBitcoinAddress: () => mockIsValidBitcoinAddress,
@@ -37,7 +38,8 @@ jest.mock('./AddressUtils', () => ({
3738
processLNDHubAddress: (...args: any[]) => mockProcessLNDHubAddress(...args),
3839
isValidNpub: () => false,
3940
isPsbt: () => false,
40-
isValidTxHex: () => false
41+
isValidTxHex: () => false,
42+
ZEUS_ECASH_GIFT_URL
4143
}));
4244
jest.mock('./TorUtils', () => ({}));
4345
jest.mock('./BackendUtils', () => ({
@@ -48,6 +50,13 @@ jest.mock('./BackendUtils', () => ({
4850
supportsLnurlAuth: () => false
4951
}));
5052

53+
let mockIsValidCashuToken = false;
54+
let mockDecodedCashuToken = {};
55+
jest.mock('./CashuUtils', () => ({
56+
isValidCashuToken: () => mockIsValidCashuToken,
57+
decodeCashuToken: () => mockDecodedCashuToken
58+
}));
59+
5160
const mockProcessLndConnectUrl = jest.fn();
5261
const mockProcessCLNRestConnectUrl = jest.fn();
5362
const mockProcessLncUrl = jest.fn();
@@ -99,6 +108,8 @@ describe('handleAnything', () => {
99108
mockIsValidLightningOffer = false;
100109
mockIsValidLNDHubAddress = false;
101110
mockIsValidNodeUri = false;
111+
mockIsValidCashuToken = false;
112+
mockDecodedCashuToken = {};
102113
});
103114

104115
describe('input sanitization', () => {
@@ -1534,4 +1545,171 @@ describe('handleAnything', () => {
15341545
).toBe(false);
15351546
});
15361547
});
1548+
1549+
// IMPORTANT: zeusln.com/e/ URLs must be checked BEFORE the lnurl block in handleAnything,
1550+
// because findlnurl() from js-lnurl can match patterns in Cashu tokens that look like bech32 lnurls.
1551+
// These tests verify the correct route is returned when findlnurl might otherwise match.
1552+
describe('zeusln.com ecash gift URLs', () => {
1553+
const validCashuToken =
1554+
'cashuBo2FteCJodHRwczovL21pbnQubWluaWJpdHMuY2FzaC9CaXRjb2luYXVjc2F0YXSComFpSAAQeTfbDMhlYXCCpGFhCGFzeEAxOWQ3YmRiNTIwMzRmOWQ5OGQwZTU5OTEwM2FkMTlmZTAyODc1ODQ2MThlYmViYTFkNzExM2FiMjI4MDM0YjNlYWNYIQI5u9Nss3cyYXJoLTwRMY4qgCNL7-J7EtBFnGOIeq6tcWFko2FlWCBYlzHfzFSuLuI31zvZGHme0QGeeEjNoGhIs6l2fbbHH2FzWCA2ecAUKZWjtQagWP7wOUVRgsHYlyOG7CUhUFdQjUed2GFyWCC67afI6qoB6bISgCWK24JOdD_-gxUyaSrgfrZJmHsBh6RhYQRhc3hANWYzMGRlMDA1NjVkYTRhZDg2Yzk1MzljYzYzMGE3NTBlYWU2OTJiY2Q5ODgwMWI0OTI0NDA1ZDAxN2UzNGE5MWFjWCECN8hTvJgIFBwN0QY3prfyf4z7BxPVLBPptzKcnb_OupNhZKNhZVggcx4XGwTyM4riizWgckD44KUJQtKHUGGjHIJHFKdPE31hc1ggqASxqD7ZTbA59c7SAHgQvLdLq_xYLL2rAVr2ziIGfkRhclgg-bTi6nbDj8Mdq9rnKhAGgrQ4w6ihk9pvu9xUommg6V-iYWlIAFAFUPBJQUZhcIGkYWEEYXN4QDBlMzhhZWIxYzIxMTk1N2U5Njg4YTdlY2YzNzQ4Y2E2MTA0MDMzNmZjZWJmMzE4M2EwMDAwOWFiYzJiMjcxNWFhY1ghAu0yuRBFejZTitq8LJufeiB2CUAEk-pHTIWqYtFcqtCSYWSjYWVYIDhNUWOMw2xaCZT4Ob8bdV5CxkErkHh-m2XUUqCKZS3WYXNYIBUltNlKIUTrHi4M1Z8SL3l3_EPp-5eOPltdS648bpVGYXJYIFZdUQc6c-waYIhOMSS_-cS19HiHZn4xZe4s65dGN8u5';
1555+
1556+
it('should route to CashuToken view for valid zeusln.com/e/ URL', async () => {
1557+
const url = `${ZEUS_ECASH_GIFT_URL}${validCashuToken}`;
1558+
mockProcessBIP21Uri.mockReturnValue({ value: url });
1559+
mockIsValidCashuToken = true;
1560+
mockDecodedCashuToken = {
1561+
token: [
1562+
{
1563+
mint: 'https://mint.minibits.cash/Bitcoin',
1564+
proofs: [{ amount: 8 }, { amount: 4 }, { amount: 4 }]
1565+
}
1566+
],
1567+
unit: 'sat'
1568+
};
1569+
1570+
const result = await handleAnything(url);
1571+
1572+
expect(result).toEqual([
1573+
'CashuToken',
1574+
{
1575+
token: validCashuToken,
1576+
decoded: expect.any(Object)
1577+
}
1578+
]);
1579+
});
1580+
1581+
it('should route to CashuToken even when findlnurl would match token content', async () => {
1582+
// This test simulates the bug where findlnurl matched patterns in Cashu tokens
1583+
const url = `${ZEUS_ECASH_GIFT_URL}${validCashuToken}`;
1584+
mockProcessBIP21Uri.mockReturnValue({ value: url });
1585+
mockIsValidCashuToken = true;
1586+
mockDecodedCashuToken = {
1587+
token: [
1588+
{
1589+
mint: 'https://mint.minibits.cash/Bitcoin',
1590+
proofs: [{ amount: 16 }]
1591+
}
1592+
],
1593+
unit: 'sat'
1594+
};
1595+
// Simulate findlnurl returning a match (which would happen with some Cashu tokens)
1596+
mockFindLnurl.mockReturnValue('lnurl1somefalsepositive');
1597+
1598+
const result = await handleAnything(url);
1599+
1600+
// Should still route to CashuToken, NOT to lnurl handling
1601+
expect(result).toEqual([
1602+
'CashuToken',
1603+
{
1604+
token: validCashuToken,
1605+
decoded: expect.any(Object)
1606+
}
1607+
]);
1608+
});
1609+
1610+
it('should return true for valid zeusln.com/e/ URL from clipboard', async () => {
1611+
const url = `${ZEUS_ECASH_GIFT_URL}${validCashuToken}`;
1612+
mockProcessBIP21Uri.mockReturnValue({ value: url });
1613+
mockIsValidCashuToken = true;
1614+
mockDecodedCashuToken = {
1615+
token: [
1616+
{
1617+
mint: 'https://mint.minibits.cash/Bitcoin',
1618+
proofs: [{ amount: 16 }]
1619+
}
1620+
],
1621+
unit: 'sat'
1622+
};
1623+
1624+
const result = await handleAnything(url, undefined, true);
1625+
1626+
expect(result).toBe(true);
1627+
});
1628+
1629+
it('should route to CashuToken for V3 token (cashuA prefix)', async () => {
1630+
const simpleToken =
1631+
'cashuAeyJ0b2tlbiI6IFt7InByb29mcyI6IFtdLCAibWludCI6ICJodHRwczovL21pbnQuZXhhbXBsZS5jb20ifV19';
1632+
const url = `${ZEUS_ECASH_GIFT_URL}${simpleToken}`;
1633+
mockProcessBIP21Uri.mockReturnValue({ value: url });
1634+
mockIsValidCashuToken = true;
1635+
mockDecodedCashuToken = {
1636+
token: [
1637+
{
1638+
mint: 'https://mint.example.com',
1639+
proofs: []
1640+
}
1641+
]
1642+
};
1643+
1644+
const result = await handleAnything(url);
1645+
1646+
expect(result).toEqual([
1647+
'CashuToken',
1648+
{
1649+
token: simpleToken,
1650+
decoded: expect.any(Object)
1651+
}
1652+
]);
1653+
});
1654+
1655+
it('should throw error for zeusln.com/e/ URL with invalid token', async () => {
1656+
const invalidToken = 'notavalidcashutoken';
1657+
const url = `${ZEUS_ECASH_GIFT_URL}${invalidToken}`;
1658+
mockProcessBIP21Uri.mockReturnValue({ value: url });
1659+
mockIsValidCashuToken = false;
1660+
1661+
await expect(handleAnything(url)).rejects.toThrow();
1662+
});
1663+
1664+
it('should return false for zeusln.com/e/ URL with invalid token from clipboard', async () => {
1665+
const invalidToken = 'notavalidcashutoken';
1666+
const url = `${ZEUS_ECASH_GIFT_URL}${invalidToken}`;
1667+
mockProcessBIP21Uri.mockReturnValue({ value: url });
1668+
mockIsValidCashuToken = false;
1669+
1670+
const result = await handleAnything(url, undefined, true);
1671+
1672+
expect(result).toBe(false);
1673+
});
1674+
1675+
it('should handle zeusln.com/e/ URL with whitespace trimmed', async () => {
1676+
const url = ` ${ZEUS_ECASH_GIFT_URL}${validCashuToken} `;
1677+
mockProcessBIP21Uri.mockReturnValue({
1678+
value: `${ZEUS_ECASH_GIFT_URL}${validCashuToken}`
1679+
});
1680+
mockIsValidCashuToken = true;
1681+
mockDecodedCashuToken = {
1682+
token: [
1683+
{
1684+
mint: 'https://mint.minibits.cash/Bitcoin',
1685+
proofs: [{ amount: 16 }]
1686+
}
1687+
],
1688+
unit: 'sat'
1689+
};
1690+
1691+
const result = await handleAnything(url);
1692+
1693+
expect(result[0]).toBe('CashuToken');
1694+
});
1695+
1696+
it('should not match similar but different URLs', async () => {
1697+
// URL that starts with zeusln.com but not /e/ path
1698+
const url = 'https://zeusln.com/download';
1699+
mockProcessBIP21Uri.mockReturnValue({ value: url });
1700+
mockIsValidCashuToken = false;
1701+
1702+
// Should fall through to error since it's not a valid payment method
1703+
await expect(handleAnything(url)).rejects.toThrow();
1704+
});
1705+
1706+
it('should not match http:// URLs (only https://)', async () => {
1707+
const url = `http://zeusln.com/e/${validCashuToken}`;
1708+
mockProcessBIP21Uri.mockReturnValue({ value: url });
1709+
mockIsValidCashuToken = false;
1710+
1711+
// http:// should not match the https:// pattern
1712+
await expect(handleAnything(url)).rejects.toThrow();
1713+
});
1714+
});
15371715
});

0 commit comments

Comments
 (0)