diff --git a/src/index.ts b/src/index.ts index 09b59ccb..939524a6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,7 +7,9 @@ import { getEncodedTokenV4, getDecodedToken, deriveKeysetId, - decodePaymentRequest + decodePaymentRequest, + getDecodedTokenBinary, + getEncodedTokenBinary } from './utils.js'; export * from './model/types/index.js'; @@ -21,7 +23,9 @@ export { getEncodedTokenV4, decodePaymentRequest, deriveKeysetId, - setGlobalRequestOptions + setGlobalRequestOptions, + getDecodedTokenBinary, + getEncodedTokenBinary }; export { injectWebSocketImpl } from './ws.js'; diff --git a/src/utils.ts b/src/utils.ts index bb6f9b0f..644f4e2a 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -228,6 +228,17 @@ export function getEncodedTokenV4(token: Token): string { if (nonHex) { throw new Error('can not encode to v4 token if proofs contain non-hex keyset id'); } + + const tokenTemplate = templateFromToken(token); + + const encodedData = encodeCBOR(tokenTemplate); + const prefix = 'cashu'; + const version = 'B'; + const base64Data = encodeUint8toBase64Url(encodedData); + return prefix + version + base64Data; +} + +function templateFromToken(token: Token): TokenV4Template { const idMap: { [id: string]: Array } = {}; const mint = token.mint; for (let i = 0; i < token.proofs.length; i++) { @@ -261,16 +272,36 @@ export function getEncodedTokenV4(token: Token): string { }) ) } as TokenV4Template; - if (token.memo) { tokenTemplate.d = token.memo; } + return tokenTemplate; +} - const encodedData = encodeCBOR(tokenTemplate); - const prefix = 'cashu'; - const version = 'B'; - const base64Data = encodeUint8toBase64Url(encodedData); - return prefix + version + base64Data; +function tokenFromTemplate(template: TokenV4Template): Token { + const proofs: Array = []; + template.t.forEach((t) => + t.p.forEach((p) => { + proofs.push({ + secret: p.s, + C: bytesToHex(p.c), + amount: p.a, + id: bytesToHex(t.i), + ...(p.d && { + dleq: { + r: bytesToHex(p.d.r), + s: bytesToHex(p.d.s), + e: bytesToHex(p.d.e) + } as SerializedDLEQ + }) + }); + }) + ); + const decodedToken: Token = { mint: template.m, proofs, unit: template.u || 'sat' }; + if (template.d) { + decodedToken.memo = template.d; + } + return decodedToken; } /** @@ -316,28 +347,7 @@ export function handleTokens(token: string): Token { } else if (version === 'B') { const uInt8Token = encodeBase64toUint8(encodedToken); const tokenData = decodeCBOR(uInt8Token) as TokenV4Template; - const proofs: Array = []; - tokenData.t.forEach((t) => - t.p.forEach((p) => { - proofs.push({ - secret: p.s, - C: bytesToHex(p.c), - amount: p.a, - id: bytesToHex(t.i), - ...(p.d && { - dleq: { - r: bytesToHex(p.d.r), - s: bytesToHex(p.d.s), - e: bytesToHex(p.d.e) - } as SerializedDLEQ - }) - }); - }) - ); - const decodedToken: Token = { mint: tokenData.m, proofs, unit: tokenData.u || 'sat' }; - if (tokenData.d) { - decodedToken.memo = tokenData.d; - } + const decodedToken = tokenFromTemplate(tokenData); return decodedToken; } throw new Error('Token version is not supported'); @@ -521,3 +531,35 @@ export function hasValidDleq(proof: Proof, keyset: MintKeys): boolean { return true; } + +function concatByteArrays(...arrays: Array): Uint8Array { + const totalLength = arrays.reduce((a, c) => a + c.length, 0); + const byteArray = new Uint8Array(totalLength); + let pointer = 0; + for (let i = 0; i < arrays.length; i++) { + byteArray.set(arrays[i], pointer); + pointer = pointer + arrays[i].length; + } + return byteArray; +} + +export function getEncodedTokenBinary(token: Token): Uint8Array { + const utf8Encoder = new TextEncoder(); + const template = templateFromToken(token); + const binaryTemplate = encodeCBOR(template); + const prefix = utf8Encoder.encode('craw'); + const version = utf8Encoder.encode('B'); + return concatByteArrays(prefix, version, binaryTemplate); +} + +export function getDecodedTokenBinary(bytes: Uint8Array): Token { + const utfDecoder = new TextDecoder(); + const prefix = utfDecoder.decode(bytes.slice(0, 4)); + const version = utfDecoder.decode(new Uint8Array([bytes[4]])); + if (prefix !== 'craw' || version !== 'B') { + throw new Error('not a valid binary token'); + } + const binaryToken = bytes.slice(5); + const decoded = decodeCBOR(binaryToken) as TokenV4Template; + return tokenFromTemplate(decoded); +} diff --git a/test/utils.test.ts b/test/utils.test.ts index 1d6b9dc7..b4905695 100644 --- a/test/utils.test.ts +++ b/test/utils.test.ts @@ -4,7 +4,7 @@ import { serializeProof } from '@cashu/crypto/modules/client'; import { test, describe, expect } from 'vitest'; -import { Keys, Proof } from '../src/model/types/index.js'; +import { Keys, Proof, Token } from '../src/model/types/index.js'; import * as utils from '../src/utils.js'; import { PUBKEYS } from './consts.js'; import { createDLEQProof } from '@cashu/crypto/modules/mint/NUT12'; @@ -415,3 +415,34 @@ describe('test zero-knowledge utilities', () => { expect(exc).toEqual(new Error('undefined key for amount 1')); }); }); + +describe('test raw tokens', () => { + const token: Token = { + mint: 'http://localhost:3338', + proofs: [ + { + id: '00ad268c4d1f5826', + amount: 1, + secret: '9a6dbb847bd232ba76db0df197216b29d3b8cc14553cd27827fc1cc942fedb4e', + C: '038618543ffb6b8695df4ad4babcde92a34a96bdcd97dcee0d7ccf98d472126792' + } + ], + memo: 'Thank you', + unit: 'sat' + }; + + test('bytes to token', () => { + const exptectedBytes = hexToBytes( + '6372617742a4617481a261694800ad268c4d1f5826617081a3616101617378403961366462623834376264323332626137366462306466313937323136623239643362386363313435353363643237383237666331636339343266656462346561635821038618543ffb6b8695df4ad4babcde92a34a96bdcd97dcee0d7ccf98d4721267926164695468616e6b20796f75616d75687474703a2f2f6c6f63616c686f73743a33333338617563736174' + ); + + const decodedToken = utils.getDecodedTokenBinary(exptectedBytes); + expect(decodedToken).toEqual(token); + }); + + test('token to bytes', () => { + const bytes = utils.getEncodedTokenBinary(token); + const decodedToken = utils.getDecodedTokenBinary(bytes); + expect(decodedToken).toEqual(token); + }); +});