Skip to content
Draft
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
116 changes: 116 additions & 0 deletions __tests__/card/cashu-topup-helpers.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
/**
* cashu-topup-helpers.spec.ts
*
* Unit tests for the Cashu card top-up helper functions.
* These are pure logic functions with no RN/NFC dependencies.
*/

import {
extractNonceFromSecret,
toCardWriteProof,
} from "@app/screens/card-screen/cashu-topup.helpers"

// ── Fixtures ──────────────────────────────────────────────────────────────

// NUT-XX worked example from spec/NUT-XX.md
const EXAMPLE_NONCE = "916c21b8c67da71e9d02f4e3adc6f30700c152e01a07ae30e3bcc6b55b0c9e5e"
const EXAMPLE_PUBKEY = "02a9acc1e48c25eeeb9289b5031cc57da9fe72f3fe2861d264bdc074209b107ba2"

/** Build a P2PK secret JSON string from (nonce, pubkey) matching the on-card format */
function makeP2PKSecret(nonce: string, pubkey: string): string {
return JSON.stringify([
"P2PK",
{
nonce,
data: pubkey,
tags: [["sigflag", "SIG_ALL"]],
},
])
}

const EXAMPLE_SECRET = makeP2PKSecret(EXAMPLE_NONCE, EXAMPLE_PUBKEY)

const EXAMPLE_PROOF = {
id: "0059534ce0bfa19a",
amount: 100,
secret: EXAMPLE_SECRET,
C: "024a43eddcf0e42dad32ca5c0e82e51d7a38e7a48b80e89d2e17cc94abb02c04c3",
}

// ── extractNonceFromSecret ────────────────────────────────────────────────

describe("extractNonceFromSecret", () => {
it("extracts nonce from a valid P2PK secret", () => {
expect(extractNonceFromSecret(EXAMPLE_SECRET)).toBe(EXAMPLE_NONCE)
})

it("extracts nonce regardless of other fields", () => {
const secretWithExtras = JSON.stringify([
"P2PK",
{
nonce: "aabbcc",
data: EXAMPLE_PUBKEY,
tags: [["sigflag", "SIG_ALL"], ["locktime", "9999999999"]],
},
])
expect(extractNonceFromSecret(secretWithExtras)).toBe("aabbcc")
})

it("throws for non-P2PK secrets", () => {
const htlcSecret = JSON.stringify([
"HTLC",
{ nonce: "deadbeef", data: "something" },
])
expect(() => extractNonceFromSecret(htlcSecret)).toThrow("Invalid P2PK secret")
})

it("throws for missing nonce field", () => {
const noNonce = JSON.stringify(["P2PK", { data: EXAMPLE_PUBKEY }])
expect(() => extractNonceFromSecret(noNonce)).toThrow("Invalid P2PK secret")
})

it("throws for invalid JSON", () => {
expect(() => extractNonceFromSecret("not-json")).toThrow("Invalid P2PK secret")
})

it("throws for plain string secrets (non-P2PK)", () => {
expect(() => extractNonceFromSecret("deadbeefdeadbeef")).toThrow(
"Invalid P2PK secret",
)
})
})

// ── toCardWriteProof ──────────────────────────────────────────────────────

describe("toCardWriteProof", () => {
it("maps a GQL proof to the card write format", () => {
const result = toCardWriteProof(EXAMPLE_PROOF)
expect(result).toEqual({
keysetId: "0059534ce0bfa19a",
amount: 100,
nonce: EXAMPLE_NONCE,
C: "024a43eddcf0e42dad32ca5c0e82e51d7a38e7a48b80e89d2e17cc94abb02c04c3",
})
})

it("propagates nonce extraction errors", () => {
const badProof = { ...EXAMPLE_PROOF, secret: "not-a-p2pk-secret" }
expect(() => toCardWriteProof(badProof)).toThrow("Invalid P2PK secret")
})

it("preserves amount exactly (no rounding)", () => {
const proofs = [1, 2, 4, 8, 16, 32, 64].map((amount) => ({
...EXAMPLE_PROOF,
amount,
}))
proofs.forEach((proof) => {
expect(toCardWriteProof(proof).amount).toBe(proof.amount)
})
})

it("uses proof.id as keysetId (not amount or C)", () => {
const result = toCardWriteProof(EXAMPLE_PROOF)
expect(result.keysetId).toBe(EXAMPLE_PROOF.id)
expect(result.keysetId).not.toBe(EXAMPLE_PROOF.C)
})
})
13 changes: 12 additions & 1 deletion app/components/card/EmptyCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,11 @@ import EmptyFlashcard from "@app/assets/icons/empty-flashcard.svg"

const width = Dimensions.get("screen").width

const EmptyCard = () => {
type EmptyCardProps = {
onCashuTopup?: () => void
}

const EmptyCard = ({ onCashuTopup }: EmptyCardProps) => {
const navigation = useNavigation<StackNavigationProp<RootStackParamList>>()
const styles = useStyles()
const { LL } = useI18nContext()
Expand All @@ -39,6 +43,13 @@ const EmptyCard = () => {
onPress={() => readFlashcard(false)}
btnStyle={{ marginBottom: 10 }}
/>
{onCashuTopup && (
<PrimaryBtn
label="💳 Top Up Flash Card"
onPress={onCashuTopup}
btnStyle={{ marginBottom: 10 }}
/>
)}
<PrimaryBtn type="outline" label="Find a Flashpoint" onPress={findFlashpoint} />
</View>
)
Expand Down
11 changes: 10 additions & 1 deletion app/components/card/Flashcard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,10 @@ import { DisplayCurrency, toBtcMoneyAmount } from "@app/types/amounts"
type Props = {
onReload: () => void
onTopup: () => void
onCashuTopup?: () => void
}

const Flashcard: React.FC<Props> = ({ onReload, onTopup }) => {
const Flashcard: React.FC<Props> = ({ onReload, onTopup, onCashuTopup }) => {
const isAuthed = useIsAuthed()
const styles = useStyles()
const { colors } = useTheme().theme
Expand Down Expand Up @@ -72,6 +73,14 @@ const Flashcard: React.FC<Props> = ({ onReload, onTopup }) => {
<View style={styles.btns}>
<IconBtn type="clear" icon="down" label={`Reload\nCard`} onPress={onReload} />
<IconBtn type="clear" icon="qr" label={`Topup via\nQR`} onPress={onTopup} />
{onCashuTopup && (
<IconBtn
type="clear"
icon="cardAdd"
label={`Flash\nTop-Up`}
onPress={onCashuTopup}
/>
)}
<IconBtn
type="clear"
icon={"cardRemove"}
Expand Down
2 changes: 1 addition & 1 deletion app/i18n/en/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -835,7 +835,7 @@ const en: BaseTranslation = {
showNostrSecret: "Chat Settings",
beginnerMode: "Disable Bitcoin Account",
advanceMode: "Enable Bitcoin Account (Advanced Mode)",
keysManagement: "Key management",
keysManagement: "Wallet backup",
showBtcAccount: "Show Bitcoin account",
hideBtcAccount: "Hide Bitcoin account"
},
Expand Down
2 changes: 1 addition & 1 deletion app/i18n/raw-i18n/source/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -772,7 +772,7 @@
"showNostrSecret": "Chat Settings",
"beginnerMode": "Disable Bitcoin Account",
"advanceMode": "Enable Bitcoin Account (Advanced Mode)",
"keysManagement": "Key management",
"keysManagement": "Wallet backup",
"showBtcAccount": "Show Bitcoin account",
"hideBtcAccount": "Hide Bitcoin account"
},
Expand Down
9 changes: 8 additions & 1 deletion app/navigation/root-navigator.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import {
} from "../screens/authentication-screen"
import { PinScreen } from "../screens/authentication-screen/pin-screen"
import { ContactsDetailScreen, ContactsScreen } from "../screens/contacts-screen"
import { CardScreen, FlashcardTopup } from "../screens/card-screen"
import { CardScreen, FlashcardTopup, CashuTopup } from "../screens/card-screen"
import { ChatList } from "@app/screens/chat"
import { DeveloperScreen } from "../screens/developer-screen"
import { EarnMapScreen } from "../screens/earns-map-screen"
Expand Down Expand Up @@ -269,6 +269,13 @@ export const RootStack = () => {
title: LL.ReceiveScreen.topupFlashcard(),
}}
/>
<RootNavigator.Screen
name="cashuTopup"
component={CashuTopup}
options={{
title: "Flash Card Top-Up",
}}
/>
<RootNavigator.Screen
name="redeemBitcoinDetail"
component={RedeemBitcoinDetailScreen}
Expand Down
1 change: 1 addition & 0 deletions app/navigation/stack-param-lists.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ export type RootStackParamList = {
priceHistory: undefined
receiveBitcoin: undefined
flashcardTopup: { flashcardLnurl: string }
cashuTopup: undefined
redeemBitcoinDetail: {
receiveDestination: ReceiveDestination
}
Expand Down
Loading