Skip to content

Commit

Permalink
Merge pull request #166 from Egge21M/write-tokenv4
Browse files Browse the repository at this point in the history
Feature: Create V4 Token
  • Loading branch information
Egge21M committed Aug 12, 2024
2 parents 79c2009 + 3544860 commit 061675f
Show file tree
Hide file tree
Showing 7 changed files with 290 additions and 121 deletions.
12 changes: 11 additions & 1 deletion src/base64.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
}
Expand All @@ -29,4 +33,10 @@ function base64urlFromBase64(str: string) {
// .replace(/=/g, '.');
}

export { encodeUint8toBase64, encodeBase64toUint8, encodeJsonToBase64, encodeBase64ToJson };
export {
encodeUint8toBase64,
encodeUint8toBase64Url,
encodeBase64toUint8,
encodeJsonToBase64,
encodeBase64ToJson
};
67 changes: 62 additions & 5 deletions src/cbor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ function encodeItem(value: any, buffer: Array<number>) {
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 {
Expand All @@ -38,16 +40,71 @@ function encodeUnsigned(value: number, buffer: Array<number>) {
}
}

function encodeByteString(value: Uint8Array, buffer: Array<number>) {
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<number>) {
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<any>, buffer: Array<number>) {
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);
}
Expand Down
3 changes: 2 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -11,6 +11,7 @@ export {
CashuWallet,
getDecodedToken,
getEncodedToken,
getEncodedTokenV4,
deriveKeysetId,
generateNewMnemonic,
deriveSeedFromMnemonic,
Expand Down
18 changes: 18 additions & 0 deletions src/model/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<V4ProofTemplate>;
};

export type TokenV4Template = {
t: Array<V4InnerToken>;
d: string;
m: string;
u: string;
};
73 changes: 66 additions & 7 deletions src/utils.ts
Original file line number Diff line number Diff line change
@@ -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<AmountPreference>): Array<number> {
const chunks: Array<number> = [];
Expand Down Expand Up @@ -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<Proof> } = {};
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...)
Expand Down Expand Up @@ -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) =>
Expand All @@ -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
Expand Down Expand Up @@ -180,6 +238,7 @@ export {
bytesToNumber,
getDecodedToken,
getEncodedToken,
getEncodedTokenV4,
hexToNumber,
splitAmount,
getDefaultAmountPreference
Expand Down
Loading

0 comments on commit 061675f

Please sign in to comment.