Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

start cashu lib with bun tests #215

Merged
merged 7 commits into from
Dec 23, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
name: CI

on:
push:
branches: [master]
pull_request:
branches: [master]

permissions:
checks: write
pull-requests: write

jobs:
test:
name: Run Tests
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4

- name: Install Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: 1.1.34

- name: Install dependencies
run: bun install

- name: Run tests
run: bun test
45 changes: 18 additions & 27 deletions app/features/user/guest-account-storage.ts
Original file line number Diff line number Diff line change
@@ -1,44 +1,35 @@
import { z } from 'zod';
import { safeJsonParse } from '~/lib/json';

const storageKey = 'guestAccount';

type GuestAccountDetails = {
id: string;
password: string;
};
const GuestAccountDetailsSchema = z.object({
id: z.string(),
password: z.string(),
});

const assertGuestAccountDetails = (
value: unknown,
): value is GuestAccountDetails => {
return (
value instanceof Object &&
'id' in value &&
typeof value.id === 'string' &&
'password' in value &&
typeof value.password === 'string'
);
};

const safeJsonParse = (value: string): unknown | null => {
try {
return JSON.parse(value);
} catch {
return null;
}
};
type GuestAccountDetails = z.infer<typeof GuestAccountDetailsSchema>;

const getGuestAccount = (): GuestAccountDetails | null => {
const dataString = localStorage.getItem(storageKey);
if (!dataString) {
return null;
}
const dataObject = safeJsonParse(dataString);
if (!assertGuestAccountDetails(dataObject)) {
const parseResult = safeJsonParse(dataString);
if (!parseResult.success) {
return null;
}
const validationResult = GuestAccountDetailsSchema.safeParse(
parseResult.data,
);
if (!validationResult.success) {
console.error(
'Invalid guest account data found in the storage',
dataObject,
parseResult.data,
);
return null;
}
return dataObject;
return validationResult.data;
};

const storeGuestAccount = (data: GuestAccountDetails) => {
Expand Down
2 changes: 2 additions & 0 deletions app/lib/cashu/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from './proof';
export * from './secret';
20 changes: 20 additions & 0 deletions app/lib/cashu/proof.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import type { Proof } from '@cashu/cashu-ts';
import { getP2PKPubkeyFromSecret } from './secret';

/**
* Get the pubkey that a list of proofs are locked to.
* @param proofs - The list of proofs to get the pubkey from.
* @returns The pubkey in the P2PK secrets
* @throws Error if there are multiple pubkeys in the list or if the secret is not a P2PK secret.
*/
export const getP2PKPubkeyFromProofs = (proofs: Proof[]): string | null => {
const pubkeys = [
...new Set(
proofs.map((p) => getP2PKPubkeyFromSecret(p.secret)).filter(Boolean),
),
];
if (pubkeys.length > 1) {
throw new Error('Received a set of proofs with multiple pubkeys');
}
return pubkeys[0] || null;
};
72 changes: 72 additions & 0 deletions app/lib/cashu/secret.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { safeJsonParse } from '../json';
import { RawNUT10SecretSchema } from './types';
import {
type NUT10Secret,
type P2PKSecret,
type PlainSecret,
type ProofSecret,
WELL_KNOWN_SECRET_KINDS,
} from './types';

/**
* Type guard to check if asecret is a NUT-10 secret
*/
export const isNUT10Secret = (secret: ProofSecret): secret is NUT10Secret => {
return typeof secret !== 'string';
};

/**
* Type guard to check if a secret is a P2PK secret
*/
export const isP2PKSecret = (secret: ProofSecret): secret is P2PKSecret => {
return (
isNUT10Secret(secret) &&
secret.kind === WELL_KNOWN_SECRET_KINDS.find((kind) => kind === 'P2PK')
);
};

/**
* Type guard to check if a secret is a plain string secret
*/
export const isPlainSecret = (secret: ProofSecret): secret is PlainSecret => {
return typeof secret === 'string';
};

/**
* Parse secret string from Proof.secret into a well-known secret [NUT-10](https://github.com/cashubtc/nuts/blob/main/10.md)
* or a string [NUT-00](https://github.com/cashubtc/nuts/blob/main/00.md)
* @param secret - The stringified secret to parse
* @returns The parsed secret as a NUT-10 secret or a string
* @throws Error if the secret is a NUT-10 secret with an invalid format
*/
export const parseSecret = (secret: string): ProofSecret => {
const parsed = safeJsonParse(secret);
if (!parsed.success) {
// if parsing fails, assume it's a plain string secret
// as defined in NUT-00
return secret;
}

// if not a plain string, then,validate the parsed JSON is a valid NUT-10 secret
const validatedSecret = RawNUT10SecretSchema.safeParse(parsed.data);
if (!validatedSecret.success) {
throw new Error('Invalid secret format');
}

const [kind, { nonce, data, tags }] = validatedSecret.data;
return { kind, nonce, data, tags };
};

/**
* Extract the public key from a P2PK secret
* @param secret - The stringified secret to parse
* @returns The public key stored in the secret's data field
* @throws Error if the secret is not a valid P2PK secret
*/
export const getP2PKPubkeyFromSecret = (secret: string): string => {
const parsedSecret = parseSecret(secret);
if (!isP2PKSecret(parsedSecret)) {
throw new Error('Secret is not a P2PK secret');
}
return parsedSecret.data;
};
132 changes: 132 additions & 0 deletions app/lib/cashu/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
import { z } from 'zod';

/**
* CAUTION: If the mint does not support spending conditions or a specific kind
* of spending condition, proofs may be treated as a regular anyone-can-spend tokens.
* Applications need to make sure to check whether the mint supports a specific kind of
* spending condition by checking the mint's info endpoint.
*/
export const WELL_KNOWN_SECRET_KINDS = ['P2PK'] as const;

const WellKnownSecretKindSchema = z.enum(WELL_KNOWN_SECRET_KINDS);

export const NUT10SecretTagSchema = z
.array(z.string())
.nonempty()
.refine((arr): arr is [string, ...string[]] => arr.length >= 1);

/**
* Tags are part of the data in a NUT-10 secret and hold additional data committed to
* and can be used for feature extensions.
*
* Tags are arrays with two or more strings being `["key", "value1", "value2", ...]`.
*
Supported tags are:

* - `sigflag`: <str> determines whether outputs have to be signed as well
* - `n_sigs`: <int> specifies the minimum number of valid signatures expected
* - `pubkeys`: <hex_str> are additional public keys that can provide signatures (allows multiple entries)
* - `locktime`: <int> is the Unix timestamp of when the lock expires
* - `refund`: <hex_str> are optional refund public keys that can exclusively spend after locktime (allows multiple entries)
*
* @example
* ```typescript
* const tag: NUT10SecretTag = ["sigflag", "SIG_INPUTS"];
* ```
*/
export type NUT10SecretTag = z.infer<typeof NUT10SecretTagSchema>;

export const NUT10SecretSchema = z.object({
/**
* well-known secret kind
* @example "P2PK"
*/
kind: z.enum(WELL_KNOWN_SECRET_KINDS),
/**
* Expresses the spending condition specific to each kind
* @example "0249098aa8b9d2fbec49ff8598feb17b592b986e62319a4fa488a3dc36387157a7"
*/
data: z.string(),
/**
* A unique random string
* @example "859d4935c4907062a6297cf4e663e2835d90d97ecdd510745d32f6816323a41f"
*/
nonce: z.string(),
/**
* Hold additional data committed to and can be used for feature extensions
* @example [["sigflag", "SIG_INPUTS"]]
*/
tags: z.array(NUT10SecretTagSchema).optional(),
});

/**
* A NUT-10 secret in a proof is stored as a JSON string of a tuple:
* [kind, {nonce, data, tags?}]
*
* When parsed, it is transformed into this object format.
* @example
* ```json
* {
* "secret": "[\"P2PK\", {
* \"nonce\": \"859d4935c4907062a6297cf4e663e2835d90d97ecdd510745d32f6816323a41f\",
* \"data\": \"0249098aa8b9d2fbec49ff8598feb17b592b986e62319a4fa488a3dc36387157a7\",
* \"tags\": [[\"sigflag\", \"SIG_INPUTS\"]]
* }]"
* }
* ```
*
* Gets parsed into:
* ```json
* {
* "kind": "P2PK",
* "data": "0249098aa8b9d2fbec49ff8598feb17b592b986e62319a4fa488a3dc36387157a7",
* "nonce": "859d4935c4907062a6297cf4e663e2835d90d97ecdd510745d32f6816323a41f",
* "tags": [["sigflag", "SIG_INPUTS"]]
* }
* ```
*/
export type NUT10Secret = z.infer<typeof NUT10SecretSchema>;

export const RawNUT10SecretSchema = z.tuple([
WellKnownSecretKindSchema,
NUT10SecretSchema.omit({ kind: true }),
]);

/**
* The raw data format of a NUT-10 secret as stored in a proof's secret field.
* JSON.parse(proof.secret) of a valid NUT-10 secret returns this type.
* @example
* ```json
* {
* "secret": "[\"P2PK\", {nonce: \"...", data: "...", tags: [["sigflag", "SIG_INPUTS"]]}]
* }
* ```
*
* Gets parsed into:
* ```typescript
* const secret: RawNUT10Secret = ["P2PK", {nonce: "...", data: "...", tags: [["sigflag", "SIG_INPUTS"]]}]
* ```
*/
export type RawNUT10Secret = z.infer<typeof RawNUT10SecretSchema>;

/**
* A plain secret is a random string
*
* @see https://github.com/cashubtc/nuts/blob/main/00.md for plain string secret format
*/
export type PlainSecret = string;

/**
* A proof secret can be either be a random string or a NUT-10 secret
*
* @see https://github.com/cashubtc/nuts/blob/main/10.md for NUT-10 secret format
* @see https://github.com/cashubtc/nuts/blob/main/00.md for plain string secret format
*/
export type ProofSecret = NUT10Secret | PlainSecret;

/**
* A P2PK secret requires a valid signature for the given pubkey
*
* @see https://github.com/cashubtc/nuts/blob/main/11.md for Pay-to-Pub-Key (P2PK) spending condition
*/
export type P2PKSecret = NUT10Secret & { kind: 'P2PK' };
13 changes: 13 additions & 0 deletions app/lib/json.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
/**
* Safely parse a JSON string.
* @returns Success and data if parsing is successful, or failure if it is not.
*/
export const safeJsonParse = <T = unknown>(
jsonString: string,
): { success: true; data: T } | { success: false } => {
try {
return { success: true, data: JSON.parse(jsonString) };
} catch {
return { success: false };
}
};
2 changes: 1 addition & 1 deletion app/lib/timeout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
const maxSetTimeoutDelay = 2 ** 31 - 1;

type LongTimeout = {
id: number | NodeJS.Timeout | null; // Tracks the latest timeout ID
id: ReturnType<typeof setTimeout> | null; // Tracks the latest timeout ID
};

/**
Expand Down
Binary file modified bun.lockb
Binary file not shown.
3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
"start": "We are running the build in node because atm we are using node as the build target."
},
"dependencies": {
"@cashu/cashu-ts": "2.1.0",
"@opensecret/react": "0.3.0",
"@radix-ui/react-dialog": "1.1.4",
"@radix-ui/react-dropdown-menu": "2.1.2",
Expand Down Expand Up @@ -52,11 +53,13 @@
"tailwindcss-animate": "1.0.7",
"use-dehydrated-state": "0.1.0",
"vaul": "1.1.2",
"zod": "3.24.1",
"zustand": "5.0.2"
},
"devDependencies": {
"@biomejs/biome": "1.9.4",
"@remix-run/dev": "2.15.0",
"@types/bun": "1.1.14",
"@types/express": "5.0.0",
"@types/react": "18.3.12",
"@types/react-dom": "18.3.1",
Expand Down
Loading
Loading