diff --git a/README.md b/README.md index 75244c60..8c413796 100644 --- a/README.md +++ b/README.md @@ -53,15 +53,42 @@ Go to the [docs](https://cashubtc.github.io/cashu-ts/docs) for detailed usage, o npm i @cashu/cashu-ts ``` -### Example +### Examples + +#### Mint tokens ```typescript -import { CashuMint, CashuWallet, getEncodedToken } from '@cashu/cashu-ts'; +import { CashuMint, CashuWallet, MintQuoteState } from '@cashu/cashu-ts'; +const mintUrl = 'http://localhost:3338'; // the mint URL +const mint = new CashuMint(mintUrl); +const wallet = new CashuWallet(mint); +const mintQuote = await wallet.createMintQuote(64); +// pay the invoice here before you continue... +const mintQuoteChecked = await wallet.checkMintQuote(mintQuote.quote); +if (mintQuoteChecked.state == MintQuoteState.PAID) { + const { proofs } = await wallet.mintTokens(64, mintQuote.quote); +} +``` + +#### Melt tokens +```typescript +import { CashuMint, CashuWallet } from '@cashu/cashu-ts'; +const mintUrl = 'http://localhost:3338'; // the mint URL const mint = new CashuMint(mintUrl); const wallet = new CashuWallet(mint); -const mintQuote = await wallet.mintQuote(64); -const tokens = await wallet.mintTokens(64, mintQuote.quote); + +const invoice = 'lnbc......'; // Lightning invoice to pay +const meltQuote = await wallet.createMeltQuote(invoice); +const amountToSend = meltQuote.amount + meltQuote.fee_reserve; + +// in a real wallet, we would coin select the correct amount of proofs from the wallet's storage +// instead of that, here we swap `proofs` with the mint to get the correct amount of proofs +const { returnChange: proofsToKeep, send: proofsToSend } = await wallet.send(amountToSend, proofs); +// store proofsToKeep in wallet .. + +const meltResponse = await wallet.meltTokens(meltQuote, proofsToSend); +// store meltResponse.change in wallet .. ``` ## Contribute diff --git a/migration-1.0.0.md b/migration-1.0.0.md index 2f42612d..ecc6ac8c 100644 --- a/migration-1.0.0.md +++ b/migration-1.0.0.md @@ -25,7 +25,7 @@ Decoding LN invoices is no longer used inside the lib. ### `CashuWallet` interface changes -**`receive()` does no longer support multi-token tokens** +**`receive()` no longer supports multi-token tokens** To reduce complexity, simplify error handling and to prepare for token V4, this feature has been removed. only the first token inside a token will be processed @@ -42,6 +42,7 @@ type MintQuoteResponse = { quote: string; paid: boolean; expiry: number; + state: MintQuoteState; }; ``` @@ -60,6 +61,9 @@ type MeltQuoteResponse = { fee_reserve: number; paid: boolean; expiry: number; + payment_preimage: string; + state: MeltQuoteState; + change?: Array; }; ``` @@ -67,6 +71,12 @@ where `quote` is the identifier to pass to `meltTokens()` --- +**`receive()`** and **`receiveTokenEntry()`** now return `Array` + +where `Proofs` are the newly created `Proofs` from the received token. Will now throw an error instead of returning `proofsWithError` + +--- + ### Model changes **`MintKeys`--> `Keys`**: diff --git a/package-lock.json b/package-lock.json index a3c6104f..109d2118 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@cashu/cashu-ts", - "version": "1.0.0-rc.12", + "version": "1.1.0-1", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "@cashu/cashu-ts", - "version": "1.0.0-rc.12", + "version": "1.1.0-1", "license": "MIT", "dependencies": { "@cashu/crypto": "^0.2.6", diff --git a/package.json b/package.json index f7b58aa0..a04b15db 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@cashu/cashu-ts", - "version": "1.0.0-rc.12", + "version": "1.1.0-1", "description": "cashu library for communicating with a cashu mint", "main": "dist/lib/es5/index.js", "module": "dist/lib/es6/index.js", diff --git a/src/base64.ts b/src/base64.ts index 7f1b0923..266bb7d4 100644 --- a/src/base64.ts +++ b/src/base64.ts @@ -4,6 +4,10 @@ function encodeUint8toBase64(uint8array: Uint8Array): string { return Buffer.from(uint8array).toString('base64'); } +function encodeUint8toBase64Url(bytes: Uint8Array): string { + return Buffer.from(bytes).toString('base64url').replace(/\=+$/, ''); +} + function encodeBase64toUint8(base64String: string): Uint8Array { return Buffer.from(base64String, 'base64'); } @@ -29,4 +33,10 @@ function base64urlFromBase64(str: string) { // .replace(/=/g, '.'); } -export { encodeUint8toBase64, encodeBase64toUint8, encodeJsonToBase64, encodeBase64ToJson }; +export { + encodeUint8toBase64, + encodeUint8toBase64Url, + encodeBase64toUint8, + encodeJsonToBase64, + encodeBase64ToJson +}; diff --git a/src/cbor.ts b/src/cbor.ts index 4a78889c..0436ff74 100644 --- a/src/cbor.ts +++ b/src/cbor.ts @@ -17,6 +17,8 @@ function encodeItem(value: any, buffer: Array) { encodeString(value, buffer); } else if (Array.isArray(value)) { encodeArray(value, buffer); + } else if (value instanceof Uint8Array) { + encodeByteString(value, buffer); } else if (typeof value === 'object') { encodeObject(value, buffer); } else { @@ -38,16 +40,71 @@ function encodeUnsigned(value: number, buffer: Array) { } } +function encodeByteString(value: Uint8Array, buffer: Array) { + const length = value.length; + + if (length < 24) { + buffer.push(0x40 + length); + } else if (length < 256) { + buffer.push(0x58, length); + } else if (length < 65536) { + buffer.push(0x59, (length >> 8) & 0xff, length & 0xff); + } else if (length < 4294967296) { + buffer.push( + 0x5a, + (length >> 24) & 0xff, + (length >> 16) & 0xff, + (length >> 8) & 0xff, + length & 0xff + ); + } else { + throw new Error('Byte string too long to encode'); + } + + for (let i = 0; i < value.length; i++) { + buffer.push(value[i]); + } +} + function encodeString(value: string, buffer: Array) { const utf8 = new TextEncoder().encode(value); - encodeUnsigned(utf8.length, buffer); - buffer[buffer.length - 1] |= 0x60; - utf8.forEach((b) => buffer.push(b)); + const length = utf8.length; + + if (length < 24) { + buffer.push(0x60 + length); + } else if (length < 256) { + buffer.push(0x78, length); + } else if (length < 65536) { + buffer.push(0x79, (length >> 8) & 0xff, length & 0xff); + } else if (length < 4294967296) { + buffer.push( + 0x7a, + (length >> 24) & 0xff, + (length >> 16) & 0xff, + (length >> 8) & 0xff, + length & 0xff + ); + } else { + throw new Error('String too long to encode'); + } + + for (let i = 0; i < utf8.length; i++) { + buffer.push(utf8[i]); + } } function encodeArray(value: Array, buffer: Array) { - encodeUnsigned(value.length, buffer); - buffer[buffer.length - 1] |= 0x80; + const length = value.length; + if (length < 24) { + buffer.push(0x80 | length); + } else if (length < 256) { + buffer.push(0x98, length); + } else if (length < 65536) { + buffer.push(0x99, length >> 8, length & 0xff); + } else { + throw new Error('Unsupported array length'); + } + for (const item of value) { encodeItem(item, buffer); } diff --git a/src/index.ts b/src/index.ts index efa9c23f..530629f1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,7 +2,7 @@ import { CashuMint } from './CashuMint.js'; import { CashuWallet } from './CashuWallet.js'; import { setGlobalRequestOptions } from './request.js'; import { generateNewMnemonic, deriveSeedFromMnemonic } from '@cashu/crypto/modules/client/NUT09'; -import { getEncodedToken, getDecodedToken, deriveKeysetId } from './utils.js'; +import { getEncodedToken, getEncodedTokenV4, getDecodedToken, deriveKeysetId } from './utils.js'; export * from './model/types/index.js'; @@ -11,6 +11,7 @@ export { CashuWallet, getDecodedToken, getEncodedToken, + getEncodedTokenV4, deriveKeysetId, generateNewMnemonic, deriveSeedFromMnemonic, diff --git a/src/model/types/index.ts b/src/model/types/index.ts index e4cc4e44..b1f9d52c 100644 --- a/src/model/types/index.ts +++ b/src/model/types/index.ts @@ -565,3 +565,21 @@ export type InvoiceData = { memo?: string; expiry?: number; }; + +export type V4ProofTemplate = { + a: number; + s: string; + c: Uint8Array; +}; + +export type V4InnerToken = { + i: Uint8Array; + p: Array; +}; + +export type TokenV4Template = { + t: Array; + d: string; + m: string; + u: string; +}; diff --git a/src/utils.ts b/src/utils.ts index 1ace779b..00d6ec01 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,9 +1,25 @@ -import { encodeBase64ToJson, encodeBase64toUint8, encodeJsonToBase64 } from './base64.js'; -import { AmountPreference, Keys, Proof, Token, TokenEntry, TokenV2 } from './model/types/index.js'; +import { + encodeBase64ToJson, + encodeBase64toUint8, + encodeJsonToBase64, + encodeUint8toBase64, + encodeUint8toBase64Url +} from './base64.js'; +import { + AmountPreference, + Keys, + Proof, + Token, + TokenEntry, + TokenV2, + TokenV4Template, + V4InnerToken, + V4ProofTemplate +} from './model/types/index.js'; import { TOKEN_PREFIX, TOKEN_VERSION } from './utils/Constants.js'; import { bytesToHex, hexToBytes } from '@noble/curves/abstract/utils'; import { sha256 } from '@noble/hashes/sha256'; -import { decodeCBOR } from './cbor.js'; +import { decodeCBOR, encodeCBOR } from './cbor.js'; function splitAmount(value: number, amountPreference?: Array): Array { const chunks: Array = []; @@ -77,6 +93,48 @@ function getEncodedToken(token: Token): string { return TOKEN_PREFIX + TOKEN_VERSION + encodeJsonToBase64(token); } +function getEncodedTokenV4(token: Token): string { + const idMap: { [id: string]: Array } = {}; + let mint: string | undefined = undefined; + for (let i = 0; i < token.token.length; i++) { + if (!mint) { + mint = token.token[i].mint; + } else { + if (mint !== token.token[i].mint) { + throw new Error('Multimint token can not be encoded as V4 token'); + } + } + for (let j = 0; j < token.token[i].proofs.length; j++) { + const proof = token.token[i].proofs[j]; + if (idMap[proof.id]) { + idMap[proof.id].push(proof); + } else { + idMap[proof.id] = [proof]; + } + } + } + const tokenTemplate: TokenV4Template = { + m: mint, + u: token.unit || 'sat', + t: Object.keys(idMap).map( + (id): V4InnerToken => ({ + i: hexToBytes(id), + p: idMap[id].map((p): V4ProofTemplate => ({ a: p.amount, s: p.secret, c: hexToBytes(p.C) })) + }) + ) + } as TokenV4Template; + + if (token.memo) { + tokenTemplate.d = token.memo; + } + + const encodedData = encodeCBOR(tokenTemplate); + const prefix = 'cashu'; + const version = 'B'; + const base64Data = encodeUint8toBase64Url(encodedData); + return prefix + version + base64Data; +} + /** * Helper function to decode cashu tokens into object * @param token an encoded cashu token (cashuAey...) @@ -106,9 +164,10 @@ function handleTokens(token: string): Token { } else if (version === 'B') { const uInt8Token = encodeBase64toUint8(encodedToken); const tokenData = decodeCBOR(uInt8Token) as { - t: { p: { a: number; s: string; c: Uint8Array }[]; i: Uint8Array }[]; + t: Array<{ p: Array<{ a: number; s: string; c: Uint8Array }>; i: Uint8Array }>; m: string; d: string; + u: string; }; const mergedTokenEntry: TokenEntry = { mint: tokenData.m, proofs: [] }; tokenData.t.forEach((tokenEntry) => @@ -121,10 +180,9 @@ function handleTokens(token: string): Token { }); }) ); - return { token: [mergedTokenEntry], memo: tokenData.d || '' }; - } else { - throw new Error('Token version is not supported'); + return { token: [mergedTokenEntry], memo: tokenData.d || '', unit: tokenData.u || 'sat' }; } + throw new Error('Token version is not supported'); } /** * Returns the keyset id of a set of keys @@ -180,6 +238,7 @@ export { bytesToNumber, getDecodedToken, getEncodedToken, + getEncodedTokenV4, hexToNumber, splitAmount, getDefaultAmountPreference diff --git a/test/cbor.test.ts b/test/cbor.test.ts index e9df53b1..be209adb 100644 --- a/test/cbor.test.ts +++ b/test/cbor.test.ts @@ -1,4 +1,5 @@ -import { decodeCBOR } from '../src/cbor'; +import { decodeCBOR, encodeCBOR } from '../src/cbor'; +import { bytesToHex, hexToBytes } from '@noble/curves/abstract/utils'; const tests = [ { @@ -55,108 +56,6 @@ const tests = [ roundtrip: true, decoded: 1000000 }, - { - cbor: 'GwAAAOjUpRAA', - hex: '1b000000e8d4a51000', - roundtrip: true, - decoded: 1000000000000 - }, - { - cbor: 'IA==', - hex: '20', - roundtrip: true, - decoded: -1 - }, - { - cbor: 'KQ==', - hex: '29', - roundtrip: true, - decoded: -10 - }, - { - cbor: 'OGM=', - hex: '3863', - roundtrip: true, - decoded: -100 - }, - { - cbor: 'OQPn', - hex: '3903e7', - roundtrip: true, - decoded: -1000 - }, - { - cbor: '+QAA', - hex: 'f90000', - roundtrip: true, - decoded: 0.0 - }, - { - cbor: '+TwA', - hex: 'f93c00', - roundtrip: true, - decoded: 1.0 - }, - { - cbor: '+z/xmZmZmZma', - hex: 'fb3ff199999999999a', - roundtrip: true, - decoded: 1.1 - }, - { - cbor: '+T4A', - hex: 'f93e00', - roundtrip: true, - decoded: 1.5 - }, - { - cbor: '+Xv/', - hex: 'f97bff', - roundtrip: true, - decoded: 65504.0 - }, - { - cbor: '+kfDUAA=', - hex: 'fa47c35000', - roundtrip: true, - decoded: 100000.0 - }, - { - cbor: '+n9///8=', - hex: 'fa7f7fffff', - roundtrip: true, - decoded: 3.4028234663852886e38 - }, - { - cbor: '+3435DyIAHWc', - hex: 'fb7e37e43c8800759c', - roundtrip: true, - decoded: 1.0e300 - }, - { - cbor: '+QAB', - hex: 'f90001', - roundtrip: true, - decoded: 5.960464477539063e-8 - }, - { - cbor: '+QQA', - hex: 'f90400', - roundtrip: true, - decoded: 6.103515625e-5 - }, - { - cbor: '+cQA', - hex: 'f9c400', - roundtrip: true, - decoded: -4.0 - }, - { - cbor: '+8AQZmZmZmZm', - hex: 'fbc010666666666666', - roundtrip: true, - decoded: -4.1 - }, { cbor: '9A==', hex: 'f4', @@ -280,15 +179,70 @@ const tests = [ d: 'D', e: 'E' } + }, + { + cbor: 'RAECAwQ=', + hex: '4401020304', + roundtrip: true, + decoded: hexToBytes('01020304') } ]; describe('cbor decoder', () => { test.each(tests)('given $hex as arguments, returns $decoded', ({ hex, decoded }) => { - //@ts-ignore - const res = decodeCBOR(Buffer.from(hex, 'hex')); - console.log(decoded); - console.log(res); + const res = decodeCBOR(hexToBytes(hex)); expect(res).toEqual(decoded); }); }); + +describe('cbor encoder', () => { + test.each(tests)('given $hex as arguments, returns $decoded', ({ hex, decoded }) => { + const res = encodeCBOR(decoded); + expect(hex).toBe(bytesToHex(res)); + }); +}); + +describe('raw v4 token cbor en/decoding', () => { + const expectedBase64 = + 'o2F0gqJhaUgA_9SLj17PgGFwgaNhYQFhc3hAYWNjMTI0MzVlN2I4NDg0YzNjZjE4NTAxNDkyMThhZjkwZjcxNmE1MmJmNGE1ZWQzNDdlNDhlY2MxM2Y3NzM4OGFjWCECRFODGd5IXVW-07KaZCvuWHk3WrnnpiDhHki6SCQh88-iYWlIAK0mjE0fWCZhcIKjYWECYXN4QDEzMjNkM2Q0NzA3YTU4YWQyZTIzYWRhNGU5ZjFmNDlmNWE1YjRhYzdiNzA4ZWIwZDYxZjczOGY0ODMwN2U4ZWVhY1ghAjRWqhENhLSsdHrr2Cw7AFrKUL9Ffr1XN6RBT6w659lNo2FhAWFzeEA1NmJjYmNiYjdjYzY0MDZiM2ZhNWQ1N2QyMTc0ZjRlZmY4YjQ0MDJiMTc2OTI2ZDNhNTdkM2MzZGNiYjU5ZDU3YWNYIQJzEpxXGeWZN5qXSmJjY8MzxWyvwObQGr5G1YCCgHicY2FtdWh0dHA6Ly9sb2NhbGhvc3Q6MzMzOGF1Y3NhdA=='; + const token = { + t: [ + { + i: hexToBytes('00ffd48b8f5ecf80'), + p: [ + { + a: 1, + s: 'acc12435e7b8484c3cf1850149218af90f716a52bf4a5ed347e48ecc13f77388', + c: hexToBytes('0244538319de485d55bed3b29a642bee5879375ab9e7a620e11e48ba482421f3cf') + } + ] + }, + { + i: hexToBytes('00ad268c4d1f5826'), + p: [ + { + a: 2, + s: '1323d3d4707a58ad2e23ada4e9f1f49f5a5b4ac7b708eb0d61f738f48307e8ee', + c: hexToBytes('023456aa110d84b4ac747aebd82c3b005aca50bf457ebd5737a4414fac3ae7d94d') + }, + { + a: 1, + s: '56bcbcbb7cc6406b3fa5d57d2174f4eff8b4402b176926d3a57d3c3dcbb59d57', + c: hexToBytes('0273129c5719e599379a974a626363c333c56cafc0e6d01abe46d5808280789c63') + } + ] + } + ], + m: 'http://localhost:3338', + u: 'sat' + }; + test('encode v4 raw', () => { + const encoded = encodeCBOR(token); + const encodedString = Buffer.from(encoded).toString('base64url'); + expect(encodedString).toBe(expectedBase64.replace(/\=+$/, '')); + }); + test('decode v4 raw', () => { + const decoded = decodeCBOR(Buffer.from(expectedBase64.replace(/\=+$/, ''), 'base64url')); + expect(decoded).toEqual(token); + }); +}); diff --git a/test/utils.test.ts b/test/utils.test.ts index 1569cd78..c686b899 100644 --- a/test/utils.test.ts +++ b/test/utils.test.ts @@ -116,6 +116,7 @@ describe('test decode token', () => { test('testing v4 Token', () => { const v3Token = { memo: 'Thank you', + unit: 'sat', token: [ { mint: 'http://localhost:3338', @@ -140,6 +141,7 @@ describe('test decode token', () => { test('testing v4 Token with multi keyset', () => { const v3Token = { memo: '', + unit: 'sat', token: [ { mint: 'http://localhost:3338', @@ -182,3 +184,71 @@ describe('test keyset derivation', () => { expect(keysetId).toBe('009a1f293253e41e'); }); }); + +describe('test v4 encoding', () => { + test('standard token', async () => { + const encodedV4 = + 'cashuBpGF0gaJhaUgArSaMTR9YJmFwgaNhYQFhc3hAOWE2ZGJiODQ3YmQyMzJiYTc2ZGIwZGYxOTcyMTZiMjlkM2I4Y2MxNDU1M2NkMjc4MjdmYzFjYzk0MmZlZGI0ZWFjWCEDhhhUP_trhpXfStS6vN6So0qWvc2X3O4NfM-Y1HISZ5JhZGlUaGFuayB5b3VhbXVodHRwOi8vbG9jYWxob3N0OjMzMzhhdWNzYXQ='; + const v3Token = { + memo: 'Thank you', + token: [ + { + mint: 'http://localhost:3338', + proofs: [ + { + secret: '9a6dbb847bd232ba76db0df197216b29d3b8cc14553cd27827fc1cc942fedb4e', + C: '038618543ffb6b8695df4ad4babcde92a34a96bdcd97dcee0d7ccf98d472126792', + id: '00ad268c4d1f5826', + amount: 1 + } + ] + } + ], + unit: 'sat' + }; + const encoded = utils.getEncodedTokenV4(v3Token); + const decodedEncodedToken = utils.getDecodedToken(encoded); + const decodedExpectedToken = utils.getDecodedToken(encodedV4); + expect(decodedEncodedToken).toEqual(v3Token); + expect(decodedExpectedToken).toEqual(decodedEncodedToken); + }); + test('multi Id token', async () => { + const encodedV4 = + 'cashuBo2F0gqJhaUgA_9SLj17PgGFwgaNhYQFhc3hAYWNjMTI0MzVlN2I4NDg0YzNjZjE4NTAxNDkyMThhZjkwZjcxNmE1MmJmNGE1ZWQzNDdlNDhlY2MxM2Y3NzM4OGFjWCECRFODGd5IXVW-07KaZCvuWHk3WrnnpiDhHki6SCQh88-iYWlIAK0mjE0fWCZhcIKjYWECYXN4QDEzMjNkM2Q0NzA3YTU4YWQyZTIzYWRhNGU5ZjFmNDlmNWE1YjRhYzdiNzA4ZWIwZDYxZjczOGY0ODMwN2U4ZWVhY1ghAjRWqhENhLSsdHrr2Cw7AFrKUL9Ffr1XN6RBT6w659lNo2FhAWFzeEA1NmJjYmNiYjdjYzY0MDZiM2ZhNWQ1N2QyMTc0ZjRlZmY4YjQ0MDJiMTc2OTI2ZDNhNTdkM2MzZGNiYjU5ZDU3YWNYIQJzEpxXGeWZN5qXSmJjY8MzxWyvwObQGr5G1YCCgHicY2FtdWh0dHA6Ly9sb2NhbGhvc3Q6MzMzOGF1Y3NhdA'; + const v3Token = { + token: [ + { + mint: 'http://localhost:3338', + proofs: [ + { + secret: 'acc12435e7b8484c3cf1850149218af90f716a52bf4a5ed347e48ecc13f77388', + C: '0244538319de485d55bed3b29a642bee5879375ab9e7a620e11e48ba482421f3cf', + id: '00ffd48b8f5ecf80', + amount: 1 + }, + { + secret: '1323d3d4707a58ad2e23ada4e9f1f49f5a5b4ac7b708eb0d61f738f48307e8ee', + C: '023456aa110d84b4ac747aebd82c3b005aca50bf457ebd5737a4414fac3ae7d94d', + id: '00ad268c4d1f5826', + amount: 2 + }, + { + secret: '56bcbcbb7cc6406b3fa5d57d2174f4eff8b4402b176926d3a57d3c3dcbb59d57', + C: '0273129c5719e599379a974a626363c333c56cafc0e6d01abe46d5808280789c63', + id: '00ad268c4d1f5826', + amount: 1 + } + ] + } + ], + memo: '', + unit: 'sat' + }; + + const encoded = utils.getEncodedTokenV4(v3Token); + const decodedEncodedToken = utils.getDecodedToken(encoded); + const decodedExpectedToken = utils.getDecodedToken(encodedV4); + expect(decodedEncodedToken).toEqual(v3Token); + expect(decodedExpectedToken).toEqual(decodedEncodedToken); + }); +});