diff --git a/src/components/HistoryTable.vue b/src/components/HistoryTable.vue index b522a4a9..2765b225 100644 --- a/src/components/HistoryTable.vue +++ b/src/components/HistoryTable.vue @@ -185,6 +185,7 @@ export default defineComponent({ this.sendData.tokens = token.getProofs(tokenObj); this.sendData.tokensBase64 = _.clone(tokensBase64); this.sendData.paymentRequest = historyToken.paymentRequest; + this.sendData.historyAmount = historyToken.amount; this.showSendTokens = true; }, }, diff --git a/src/components/ReceiveTokenDialog.vue b/src/components/ReceiveTokenDialog.vue index 8bc490fd..01d5e4df 100644 --- a/src/components/ReceiveTokenDialog.vue +++ b/src/components/ReceiveTokenDialog.vue @@ -99,7 +99,7 @@ @@ -114,8 +114,9 @@ > Lock +
+ + + + {{ + ndefSupported ? "Read from NFC card" : "NDEF unsupported" + }} + + NFC + - Payment Request + Request diff --git a/src/components/SendTokenDialog.vue b/src/components/SendTokenDialog.vue index 03440eb5..eb08a40b 100644 --- a/src/components/SendTokenDialog.vue +++ b/src/components/SendTokenDialog.vue @@ -244,8 +244,13 @@
- Ecash + {{ + sendData.historyAmount && sendData.historyAmount < 0 + ? "Sent" + : "Received" + }} + Ecash
@@ -259,6 +264,11 @@ {{ displayUnit }}
+
+ + Fees: {{ formatCurrency(paidFees, tokenUnit) }} + +
+ + {{ + ndefSupported ? "Flash to NFC card" : "NDEF unsupported" + }} + + Delete from history - Close
@@ -380,7 +425,12 @@ import { mapActions, mapState, mapWritableState } from "pinia"; import ChooseMint from "components/ChooseMint.vue"; import { UR, UREncoder } from "@gandlaf21/bc-ur"; import SendPaymentRequest from "./SendPaymentRequest.vue"; - +import { + notifyError, + notifySuccess, + notify, + notifyWarning, +} from "src/js/notify.ts"; export default defineComponent({ name: "SendTokenDialog", mixins: [windowMixin], @@ -414,6 +464,8 @@ export default defineComponent({ framentInervalSlow: 500, fragmentSpeedLabel: "F", isV4Token: false, + scanningCard: false, + ndefSupported: "NDEFReader" in globalThis, }; }, computed: { @@ -439,6 +491,7 @@ export default defineComponent({ ...mapState(useSettingsStore, [ "checkSentTokens", "includeFeesInSendAmount", + "nfcEncoding", ]), ...mapState(useWorkersStore, ["tokenWorkerRunning"]), // TOKEN METHODS @@ -458,6 +511,9 @@ export default defineComponent({ let mint = token.getMint(token.decode(this.sendData.tokensBase64)); return mint; }, + paidFees: function () { + return this.sumProofs - Math.abs(this.sendData.historyAmount); + }, displayMemo: function () { return token.getMemo(token.decode(this.sendData.tokensBase64)); }, @@ -648,6 +704,104 @@ export default defineComponent({ this.showDeleteDialog = false; this.clearAllWorkers(); }, + writeTokensToCard: function () { + if (!this.scanningCard) { + try { + this.ndef = new NDEFReader(); + this.controller = new AbortController(); + const signal = this.controller.signal; + this.ndef + .scan({ signal }) + .then(() => { + console.log("> Scan started"); + + this.ndef.onreadingerror = (error) => { + console.error(`Cannot read NDEF data! ${error}`); + notifyError("Cannot read data from the NFC tag"); + this.controller.abort(); + this.scanningCard = false; + }; + + this.ndef.onreading = ({ message, serialNumber }) => { + console.log(`Read card ${serialNumber}`); + this.controller.abort(); + this.scanningCard = false; + try { + let records = []; + switch (this.nfcEncoding) { + case "text": + records = [ + { + recordType: "text", + data: `${this.sendData.tokensBase64}`, + }, + ]; + break; + case "weburl": + records = [ + { + recordType: "url", + data: `${window.location}#token=${this.sendData.tokensBase64}`, + }, + ]; + break; + case "binary": + throw new Error("Binary encoding not supported yet"); + /* + const data = null; + records = [ + { + recordType: "mime", + mediaType: "application/octet-stream", + data: data, + }, + ]; + break; + */ + default: + throw new Error( + `Unknown NFC encoding: ${this.nfcEncoding}` + ); + } + this.ndef + .write({ records: records }, { overwrite: true }) + .then(() => { + console.log("Successfully flashed tokens to card!"); + notifySuccess("Successfully flashed tokens to card!"); + this.showSendTokens = false; + }) + .catch((err) => { + console.error( + `NFC write failed: The card may not have enough capacity (needed ${records[0].data.length} bytes).` + ); + notifyError( + `NFC write failed: The card may not have enough capacity (needed ${records[0].data.length} bytes).` + ); + }); + } catch (err) { + console.error(`NFC error: ${err.message}`); + notifyError(`NFC error: ${err.message}`); + } + }; + this.scanningCard = true; + }) + .catch((error) => { + console.error(`NFC error: ${error.message}`); + notifyError(`NFC error: ${error.message}`); + this.scanningCard = false; + }); + notifyWarning("This will overwrite your card!"); + } catch (error) { + console.error(`NFC error: ${error.message}`); + notifyError(`NFC error: ${error.message}`); + this.scanningCard = false; + } + } + }, + closeCardScanner: function () { + this.controller.abort(); + this.scanningCard = false; + }, lockTokens: async function () { let sendAmount = this.sendData.amount; // if unit is USD, multiply by 100 @@ -704,8 +858,9 @@ export default defineComponent({ // update UI this.sendData.tokens = sendProofs; - this.sendData.tokensBase64 = this.serializeProofs(sendProofs); + this.sendData.historyAmount = -this.sendData.amount; + this.addPendingToken({ amount: -sendAmount, serializedProofs: this.sendData.tokensBase64, diff --git a/src/components/SettingsView.vue b/src/components/SettingsView.vue index 273600d0..75d3dd4b 100644 --- a/src/components/SettingsView.vue +++ b/src/components/SettingsView.vue @@ -533,6 +533,91 @@ + +
+ + + + WebNFC + + Choose the encoding for writing to NFC cards + + + + + + + + + Text + Store token in plain text + + + + + + + + URL + + Store URL to this wallet with token + + + + + + +
+
@@ -1062,6 +1147,7 @@ export default defineComponent({ nip46Token: "", nip07SignerAvailable: false, newRelay: "", + ndefSupported: "NDEFReader" in globalThis, }; }, computed: { @@ -1069,6 +1155,7 @@ export default defineComponent({ "getBitcoinPrice", "checkSentTokens", "useWebsockets", + "nfcEncoding", ]), ...mapState(useP2PKStore, ["p2pkKeys"]), ...mapWritableState(useP2PKStore, ["showP2PKDialog"]), diff --git a/src/pages/WalletPage.vue b/src/pages/WalletPage.vue index 7f94f311..d87c442c 100644 --- a/src/pages/WalletPage.vue +++ b/src/pages/WalletPage.vue @@ -215,9 +215,8 @@ import { useNPCStore } from "src/stores/npubcash"; import { useNostrStore } from "src/stores/nostr"; import { usePRStore } from "src/stores/payment-request"; import { useStorageStore } from "src/stores/storage"; - import ReceiveTokenDialog from "src/components/ReceiveTokenDialog.vue"; -import { notifyError, notifySuccess, notify } from "../js/notify"; +import { notifyError, notify } from "../js/notify"; export default { mixins: [windowMixin], diff --git a/src/stores/nostr.ts b/src/stores/nostr.ts index 6f0ce8a7..da05adb3 100644 --- a/src/stores/nostr.ts +++ b/src/stores/nostr.ts @@ -403,7 +403,7 @@ export const useNostrStore = defineStore("nostr", { const receiveStore = useReceiveTokensStore(); const prStore = usePRStore(); const sendTokensStore = useSendTokensStore(); - + const tokensStore = useTokensStore(); const proofs = payload.proofs; const mint = payload.mint; const unit = payload.unit; @@ -415,7 +415,7 @@ export const useNostrStore = defineStore("nostr", { const tokenStr = getEncodedTokenV4(token); - const tokenInHistory = this.tokenAlreadyInHistory(tokenStr); + const tokenInHistory = tokensStore.tokenAlreadyInHistory(tokenStr); if (tokenInHistory && tokenInHistory.amount > 0) { console.log("### incoming token already in history"); return; @@ -453,18 +453,14 @@ export const useNostrStore = defineStore("nostr", { await this.addPendingTokenToHistory(tokenStr); } }, - tokenAlreadyInHistory: function (tokenStr: string): HistoryToken | undefined { - const tokensStore = useTokensStore(); - return tokensStore.historyTokens.find((t) => t.token === tokenStr); - }, addPendingTokenToHistory: function (tokenStr: string, verbose = true) { const receiveStore = useReceiveTokensStore(); - if (this.tokenAlreadyInHistory(tokenStr)) { + const tokensStore = useTokensStore(); + if (tokensStore.tokenAlreadyInHistory(tokenStr)) { notifySuccess("Ecash already in history"); receiveStore.showReceiveTokens = false; return; } - const tokensStore = useTokensStore(); const decodedToken = token.decode(tokenStr); if (decodedToken == undefined) { throw Error('could not decode token') diff --git a/src/stores/receiveTokensStore.js b/src/stores/receiveTokensStore.js index 15f01592..f8665c98 100644 --- a/src/stores/receiveTokensStore.js +++ b/src/stores/receiveTokensStore.js @@ -4,6 +4,8 @@ import { useUiStore } from "./ui"; import { useP2PKStore } from "./p2pk"; import { useWalletStore } from "./wallet"; import token from "src/js/token"; +import { useTokensStore } from "./tokens"; +import { notifyError, notifySuccess, notify } from "../js/notify"; export const useReceiveTokensStore = defineStore("receiveTokensStore", { state: () => ({ @@ -12,6 +14,7 @@ export const useReceiveTokensStore = defineStore("receiveTokensStore", { tokensBase64: "", p2pkPrivateKey: "", }, + scanningCard: false, }), actions: { decodeToken: function (encoded_token) { @@ -31,8 +34,6 @@ export const useReceiveTokensStore = defineStore("receiveTokensStore", { const mintStore = useMintsStore(); const walletStore = useWalletStore(); const receiveStore = useReceiveTokensStore(); - const uIStore = useUiStore(); - receiveStore.showReceiveTokens = false; console.log("### receive tokens", receiveStore.receiveData.tokensBase64); if (receiveStore.receiveData.tokensBase64.length == 0) { @@ -56,6 +57,7 @@ export const useReceiveTokensStore = defineStore("receiveTokensStore", { } // redeem the token await walletStore.redeem(receiveStore.receiveData.tokensBase64); + receiveStore.showReceiveTokens = false; }, receiveIfDecodes: async function () { try { @@ -69,5 +71,100 @@ export const useReceiveTokensStore = defineStore("receiveTokensStore", { return false; } }, + toggleScanner: function () { + const receiveStore = useReceiveTokensStore(); + const tokenStore = useTokensStore(); + if (this.scanningCard === false) { + try { + this.ndef = new window.NDEFReader(); + this.controller = new AbortController(); + const signal = this.controller.signal; + this.ndef + .scan({ signal }) + .then(() => { + console.log("> Scan started"); + + this.ndef.addEventListener("readingerror", () => { + console.error("Cannot read data from the NFC tag."); + notifyError("Cannot read data from the NFC tag."); + this.controller.abort(); + this.scanningCard = false; + }); + + this.ndef.addEventListener( + "reading", + ({ message, serialNumber }) => { + try { + const record = message.records[0]; + const recordType = record.recordType; + let tokenStr = ""; + switch (recordType) { + case "text": + const text = new TextDecoder().decode(record.data); + if (!text.startsWith("cashu")) { + throw new Error( + "text does not contain a cashu token" + ); + } + tokenStr = text; + break; + case "url": + const url = new TextDecoder().decode(record.data); + const i = url.indexOf("#token=cashu"); + if (i === -1) { + throw new Error("URL does not contain a cashu token"); + } + tokenStr = url.substring(i + 7); + break; + case "mime": + if (record.mediaType !== "application/octet-stream") { + throw new Error("binary data expected"); + } + const data = new Uint8Array(record.data.buffer); + const prefix = String.fromCharCode(...data.slice(0, 4)); + if (prefix !== "craw") { + throw new Error( + "binary data does not contain a cashu token" + ); + } + // TODO: decode the binary token from data + throw new Error( + "binary token parsing not implemented yet" + ); + break; + default: + throw new Error(`unsupported recordType ${recordType}`); + } + const historyToken = + tokenStore.tokenAlreadyInHistory(tokenStr); + if (!historyToken || historyToken.status === "pending") { + receiveStore.receiveData.tokensBase64 = tokenStr; + receiveStore.showReceiveTokens = true; + } else { + notify("Token already in history."); + } + } catch (err) { + console.error(`Something went wrong! ${err}`); + notifyError(`Something went wrong! ${err}`); + } + this.controller.abort(); + this.scanningCard = false; + } + ); + this.scanningCard = true; + }) + .catch((error) => { + console.error(`Scan error: ${error.message}`); + notifyError(`Scan error: ${error.message}`); + }); + } catch (error) { + console.error(`NFC error: ${error.message}`); + notifyError(`NFC error: ${error.message}`); + } + } else { + this.controller.abort(); + this.scanningCard = false; + } + }, }, }); diff --git a/src/stores/sendTokensStore.ts b/src/stores/sendTokensStore.ts index 8f94c817..9a384f2e 100644 --- a/src/stores/sendTokensStore.ts +++ b/src/stores/sendTokensStore.ts @@ -1,5 +1,6 @@ import { defineStore } from "pinia"; import { decodePaymentRequest, PaymentRequest } from "@cashu/cashu-ts"; +import { HistoryToken } from "./tokens"; export const useSendTokensStore = defineStore("sendTokensStore", { state: () => ({ @@ -7,28 +8,34 @@ export const useSendTokensStore = defineStore("sendTokensStore", { showLockInput: false, sendData: { amount: null, + historyAmount: null, memo: "", tokens: "", tokensBase64: "", p2pkPubkey: "", paymentRequest: undefined, + historyToken: undefined, } as { amount: number | null; + historyAmount: number | null; memo: string; tokens: string; tokensBase64: string; p2pkPubkey: string; paymentRequest?: PaymentRequest; + historyToken: HistoryToken | undefined; }, }), actions: { clearSendData() { this.sendData.amount = null; + this.sendData.historyAmount = null; this.sendData.memo = ""; this.sendData.tokens = ""; this.sendData.tokensBase64 = ""; this.sendData.p2pkPubkey = ""; this.sendData.paymentRequest = undefined; + this.sendData.historyToken = undefined; } }, }); diff --git a/src/stores/settings.ts b/src/stores/settings.ts index 33392d7b..66ebcfab 100644 --- a/src/stores/settings.ts +++ b/src/stores/settings.ts @@ -1,16 +1,39 @@ import { defineStore } from "pinia"; import { useLocalStorage } from "@vueuse/core"; -const defaultNostrRelays = ["wss://relay.damus.io", "wss://relay.8333.space/", "wss://nos.lol"] +const defaultNostrRelays = [ + "wss://relay.damus.io", + "wss://relay.8333.space/", + "wss://nos.lol", +]; export const useSettingsStore = defineStore("settings", { state: () => { return { - getBitcoinPrice: useLocalStorage("cashu.settings.getBitcoinPrice", false), - checkSentTokens: useLocalStorage("cashu.settings.checkSentTokens", true), - useWebsockets: useLocalStorage("cashu.settings.useWebsockets", false), - defaultNostrRelays: useLocalStorage("cashu.settings.defaultNostrRelays", defaultNostrRelays), - includeFeesInSendAmount: useLocalStorage("cashu.settings.includeFeesInSendAmount", false), - } - } + getBitcoinPrice: useLocalStorage( + "cashu.settings.getBitcoinPrice", + false + ), + checkSentTokens: useLocalStorage( + "cashu.settings.checkSentTokens", + true + ), + useWebsockets: useLocalStorage( + "cashu.settings.useWebsockets", + false + ), + defaultNostrRelays: useLocalStorage( + "cashu.settings.defaultNostrRelays", + defaultNostrRelays + ), + includeFeesInSendAmount: useLocalStorage( + "cashu.settings.includeFeesInSendAmount", + false + ), + nfcEncoding: useLocalStorage( + "cashu.settings.nfcEncoding", + "weburl" + ), + }; + }, }); diff --git a/src/stores/tokens.ts b/src/stores/tokens.ts index 4e704a5a..e583bca7 100644 --- a/src/stores/tokens.ts +++ b/src/stores/tokens.ts @@ -2,6 +2,7 @@ import { useLocalStorage } from "@vueuse/core"; import { date } from "quasar"; import { defineStore } from "pinia"; import { PaymentRequest, Proof, Token } from "@cashu/cashu-ts"; +import token from "src/js/token"; /** * The tokens store handles everything related to tokens and proofs @@ -121,7 +122,10 @@ export const useTokensStore = defineStore("tokens", { if (index >= 0) { this.historyTokens.splice(index, 1); } - } + }, + tokenAlreadyInHistory(tokenStr: string): HistoryToken | undefined { + return this.historyTokens.find((t) => t.token === tokenStr); + }, }, });