Skip to content

Commit

Permalink
added v4 token read from cashubtc/development (#5)
Browse files Browse the repository at this point in the history
* updated tests

* added cbor-x

* added tokenv4 parsing / removed depracated token

* remove cbor dependency

* added byte id and C

* added testcases and multi token

* specified return type

* cleanup rebase

* added cbor test cases

* fixed 16 bit float parsing

* remove .js suffixes from imports due to rn

---------

Co-authored-by: Egge <[email protected]>
  • Loading branch information
KraXen72 and Egge21M authored Jul 22, 2024
1 parent 84e9a76 commit 799437e
Show file tree
Hide file tree
Showing 8 changed files with 5,394 additions and 208 deletions.
4,871 changes: 4,764 additions & 107 deletions package-lock.json

Large diffs are not rendered by default.

229 changes: 229 additions & 0 deletions src/cbor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,229 @@
export function encodeCBOR(value: any) {
const buffer: Array<number> = [];
encodeItem(value, buffer);
return new Uint8Array(buffer);
}

function encodeItem(value: any, buffer: Array<number>) {
if (value === null) {
buffer.push(0xf6);
} else if (value === undefined) {
buffer.push(0xf7);
} else if (typeof value === 'boolean') {
buffer.push(value ? 0xf5 : 0xf4);
} else if (typeof value === 'number') {
encodeUnsigned(value, buffer);
} else if (typeof value === 'string') {
encodeString(value, buffer);
} else if (Array.isArray(value)) {
encodeArray(value, buffer);
} else if (typeof value === 'object') {
encodeObject(value, buffer);
} else {
throw new Error('Unsupported type');
}
}

function encodeUnsigned(value: number, buffer: Array<number>) {
if (value < 24) {
buffer.push(value);
} else if (value < 256) {
buffer.push(0x18, value);
} else if (value < 65536) {
buffer.push(0x19, value >> 8, value & 0xff);
} else if (value < 4294967296) {
buffer.push(0x1a, value >> 24, (value >> 16) & 0xff, (value >> 8) & 0xff, value & 0xff);
} else {
throw new Error('Unsupported integer size');
}
}

function encodeString(value: string, buffer: Array<number>) {
const utf8 = new TextEncoder().encode(value);
encodeUnsigned(utf8.length, buffer);
buffer[buffer.length - 1] |= 0x60;
utf8.forEach((b) => buffer.push(b));
}

function encodeArray(value: Array<any>, buffer: Array<number>) {
encodeUnsigned(value.length, buffer);
buffer[buffer.length - 1] |= 0x80;
for (const item of value) {
encodeItem(item, buffer);
}
}

function encodeObject(value: { [key: string]: any }, buffer: Array<number>) {
const keys = Object.keys(value);
encodeUnsigned(keys.length, buffer);
buffer[buffer.length - 1] |= 0xa0;
for (const key of keys) {
encodeString(key, buffer);
encodeItem(value[key], buffer);
}
}
type DecodeResult = {
value: any;
offset: number;
};

export function decodeCBOR(data: Uint8Array): any {
const view = new DataView(data.buffer, data.byteOffset, data.byteLength);
const result = decodeItem(view, 0);
return result.value;
}

function decodeItem(view: DataView, offset: number): DecodeResult {
if (offset >= view.byteLength) {
throw new Error('Unexpected end of data');
}
const initialByte = view.getUint8(offset++);
const majorType = initialByte >> 5;
const additionalInfo = initialByte & 0x1f;

switch (majorType) {
case 0:
return decodeUnsigned(view, offset, additionalInfo);
case 1:
return decodeSigned(view, offset, additionalInfo);
case 2:
return decodeByteString(view, offset, additionalInfo);
case 3:
return decodeString(view, offset, additionalInfo);
case 4:
return decodeArray(view, offset, additionalInfo);
case 5:
return decodeMap(view, offset, additionalInfo);
case 7:
return decodeSimpleAndFloat(view, offset, additionalInfo);
default:
throw new Error(`Unsupported major type: ${majorType}`);
}
}

function decodeLength(view: DataView, offset: number, additionalInfo: number): DecodeResult {
if (additionalInfo < 24) return { value: additionalInfo, offset };
if (additionalInfo === 24) return { value: view.getUint8(offset++), offset };
if (additionalInfo === 25) {
const value = view.getUint16(offset, false);
offset += 2;
return { value, offset };
}
if (additionalInfo === 26) {
const value = view.getUint32(offset, false);
offset += 4;
return { value, offset };
}
if (additionalInfo === 27) {
const hi = view.getUint32(offset, false);
const lo = view.getUint32(offset + 4, false);
offset += 8;
return { value: hi * 2 ** 32 + lo, offset };
}
throw new Error(`Unsupported length: ${additionalInfo}`);
}

function decodeUnsigned(view: DataView, offset: number, additionalInfo: number): DecodeResult {
const { value, offset: newOffset } = decodeLength(view, offset, additionalInfo);
return { value, offset: newOffset };
}

function decodeSigned(view: DataView, offset: number, additionalInfo: number): DecodeResult {
const { value, offset: newOffset } = decodeLength(view, offset, additionalInfo);
return { value: -1 - value, offset: newOffset };
}

function decodeByteString(view: DataView, offset: number, additionalInfo: number): DecodeResult {
const { value: length, offset: newOffset } = decodeLength(view, offset, additionalInfo);
if (newOffset + length > view.byteLength) {
throw new Error('Byte string length exceeds data length');
}
const value = new Uint8Array(view.buffer, view.byteOffset + newOffset, length);
return { value, offset: newOffset + length };
}

function decodeString(view: DataView, offset: number, additionalInfo: number): DecodeResult {
const { value: length, offset: newOffset } = decodeLength(view, offset, additionalInfo);
if (newOffset + length > view.byteLength) {
throw new Error('String length exceeds data length');
}
const bytes = new Uint8Array(view.buffer, view.byteOffset + newOffset, length);
const value = new TextDecoder().decode(bytes);
return { value, offset: newOffset + length };
}

function decodeArray(view: DataView, offset: number, additionalInfo: number): DecodeResult {
const { value: length, offset: newOffset } = decodeLength(view, offset, additionalInfo);
const array = [];
let currentOffset = newOffset;
for (let i = 0; i < length; i++) {
const result = decodeItem(view, currentOffset);
array.push(result.value);
currentOffset = result.offset;
}
return { value: array, offset: currentOffset };
}

function decodeMap(view: DataView, offset: number, additionalInfo: number): DecodeResult {
const { value: length, offset: newOffset } = decodeLength(view, offset, additionalInfo);
const map: { [key: string]: any } = {};
let currentOffset = newOffset;
for (let i = 0; i < length; i++) {
const keyResult = decodeItem(view, currentOffset);
const valueResult = decodeItem(view, keyResult.offset);
map[keyResult.value] = valueResult.value;
currentOffset = valueResult.offset;
}
return { value: map, offset: currentOffset };
}

function decodeFloat16(uint16: number): number {
const exponent = (uint16 & 0x7c00) >> 10;
const fraction = uint16 & 0x03ff;
const sign = uint16 & 0x8000 ? -1 : 1;

if (exponent === 0) {
return sign * 2 ** -14 * (fraction / 1024);
} else if (exponent === 0x1f) {
return fraction ? NaN : sign * Infinity;
}
return sign * 2 ** (exponent - 15) * (1 + fraction / 1024);
}

function decodeSimpleAndFloat(
view: DataView,
offset: number,
additionalInfo: number
): DecodeResult {
if (additionalInfo < 24) {
switch (additionalInfo) {
case 20:
return { value: false, offset };
case 21:
return { value: true, offset };
case 22:
return { value: null, offset };
case 23:
return { value: undefined, offset };
default:
throw new Error(`Unknown simple value: ${additionalInfo}`);
}
}
if (additionalInfo === 24) return { value: view.getUint8(offset++), offset };
if (additionalInfo === 25) {
const value = decodeFloat16(view.getUint16(offset, false));
offset += 2;
return { value, offset };
}
if (additionalInfo === 26) {
const value = view.getFloat32(offset, false);
offset += 4;
return { value, offset };
}
if (additionalInfo === 27) {
const value = view.getFloat64(offset, false);
offset += 8;
return { value, offset };
}
throw new Error(`Unknown simple or float value: ${additionalInfo}`);
}
4 changes: 2 additions & 2 deletions src/legacy/nut-04.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { MintQuoteResponse } from '../model/types/index.js';
import { MintQuoteState } from '../model/types/index.js';
import type { MintQuoteResponse } from '../model/types/index';
import { MintQuoteState } from '../model/types/index';

export type MintQuoteResponsePaidDeprecated = {
paid?: boolean;
Expand Down
4 changes: 2 additions & 2 deletions src/legacy/nut-05.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { MeltQuoteResponse } from '../model/types/index.js';
import { MeltQuoteState } from '../model/types/index.js';
import type { MeltQuoteResponse } from '../model/types/index';
import { MeltQuoteState } from '../model/types/index';

export type MeltQuoteResponsePaidDeprecated = {
paid?: boolean;
Expand Down
2 changes: 1 addition & 1 deletion src/legacy/nut-06.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { MintContactInfo, GetInfoResponse } from '../model/types/index.js';
import type { MintContactInfo, GetInfoResponse } from '../model/types/index';

export function handleMintInfoContactFieldDeprecated(data: GetInfoResponse) {
// Monkey patch old contact field ["email", "[email protected]"] Array<[string, string]>; to new contact field [{method: "email", info: "me@mail.com"}] Array<MintContactInfo>
Expand Down
47 changes: 30 additions & 17 deletions src/utils.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { encodeBase64ToJson, encodeJsonToBase64 } from './base64';
import { AmountPreference, Keys, Proof, Token, TokenV2 } from './model/types/index';
import { encodeBase64ToJson, encodeBase64toUint8, encodeJsonToBase64 } from './base64';
import { AmountPreference, Keys, Proof, Token, TokenEntry, TokenV2 } from './model/types/index';
import { TOKEN_PREFIX, TOKEN_VERSION } from './utils/Constants';
import { bytesToHex, hexToBytes } from '@noble/curves/abstract/utils';
import { sha256 } from '@noble/hashes/sha256';
import { decodeCBOR } from './cbor';

function splitAmount(value: number, amountPreference?: Array<AmountPreference>): Array<number> {
const chunks: Array<number> = [];
Expand Down Expand Up @@ -81,9 +82,9 @@ function getEncodedToken(token: Token): string {
* @param token an encoded cashu token (cashuAey...)
* @returns cashu token object
*/
function getDecodedToken(token: string): Token {
function getDecodedToken(token: string) {
// remove prefixes
const uriPrefixes = ['web+cashu://', 'cashu://', 'cashu:', 'cashuA'];
const uriPrefixes = ['web+cashu://', 'cashu://', 'cashu:', 'cashu'];
uriPrefixes.forEach((prefix) => {
if (!token.startsWith(prefix)) {
return;
Expand All @@ -98,20 +99,32 @@ function getDecodedToken(token: string): Token {
* @returns
*/
function handleTokens(token: string): Token {
const obj = encodeBase64ToJson<TokenV2 | Array<Proof> | Token>(token);

// check if v3
if ('token' in obj) {
return obj;
}

// check if v1
if (Array.isArray(obj)) {
return { token: [{ proofs: obj, mint: '' }] };
const version = token.slice(0, 1);
const encodedToken = token.slice(1);
if (version === 'A') {
return encodeBase64ToJson<Token>(encodedToken);
} else if (version === 'B') {
const uInt8Token = encodeBase64toUint8(encodedToken);
const tokenData = decodeCBOR(uInt8Token) as {
t: { p: { a: number; s: string; c: Uint8Array }[]; i: Uint8Array }[];
m: string;
d: string;
};
const mergedTokenEntry: TokenEntry = { mint: tokenData.m, proofs: [] };
tokenData.t.forEach((tokenEntry) =>
tokenEntry.p.forEach((p) => {
mergedTokenEntry.proofs.push({
secret: p.s,
C: bytesToHex(p.c),
amount: p.a,
id: bytesToHex(tokenEntry.i)
});
})
);
return { token: [mergedTokenEntry], memo: tokenData.d || '' };
} else {
throw new Error('Token version is not supported');
}

// if v2 token return v3 format
return { token: [{ proofs: obj.proofs, mint: obj?.mints[0]?.url ?? '' }] };
}
/**
* Returns the keyset id of a set of keys
Expand Down
Loading

0 comments on commit 799437e

Please sign in to comment.