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 3 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
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;
};
99 changes: 99 additions & 0 deletions app/lib/cashu/secret.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import { z } from 'zod';
import {
type NUT10Secret,
type NUT10SecretData,
type NUT10SecretTag,
type P2PKSecret,
type ParsedNUT10Secret,
type PlainSecret,
type ProofSecret,
WELL_KNOWN_SECRET_KINDS,
} from './types';

const NUT10SecretTagSchema = z
.tuple([z.string(), z.string()])
.rest(z.string()) satisfies z.ZodType<NUT10SecretTag>;

const NUT10SecretDataSchema = z.object({
nonce: z.string(),
data: z.string(),
tags: z.array(NUT10SecretTagSchema).optional(),
}) satisfies z.ZodType<NUT10SecretData>;

const WellKnownSecretKindSchema = z.enum(WELL_KNOWN_SECRET_KINDS);

const NUT10SecretSchema = z.tuple([
WellKnownSecretKindSchema,
NUT10SecretDataSchema,
]) satisfies z.ZodType<ParsedNUT10Secret>;

/**
* 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 => {
let parsed: unknown;
try {
parsed = JSON.parse(secret);
} catch {
// If JSON parsing fails, assume it's a plain string secret
// as defined in NUT-00
return secret;
}

try {
const validatedSecret = NUT10SecretSchema.parse(parsed);
gudnuf marked this conversation as resolved.
Show resolved Hide resolved
const [kind, data] = validatedSecret;

return {
kind,
...data,
};
} catch (error) {
if (error instanceof z.ZodError) {
throw new Error('Invalid secret format');
}
throw error;
}
};

/**
* 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;
};
127 changes: 127 additions & 0 deletions app/lib/cashu/types.ts
gudnuf marked this conversation as resolved.
Show resolved Hide resolved
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
/**
* 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)
gudnuf marked this conversation as resolved.
Show resolved Hide resolved
* - `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 = [string, ...string[]];

/**
* 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
gudnuf marked this conversation as resolved.
Show resolved Hide resolved
* spending condition by checking the mint's info endpoint.
*/
export const WELL_KNOWN_SECRET_KINDS = ['P2PK'] as const;

/**
* the kind of the spending condition
*/
export type WellKnownSecretKind = (typeof WELL_KNOWN_SECRET_KINDS)[number];

/**
* The data from a parsed stringified NUT-10 secret
*/
export type NUT10SecretData = {
nonce: string;
data: string;
tags?: NUT10SecretTag[];
gudnuf marked this conversation as resolved.
Show resolved Hide resolved
};

/**
* The raw NUT-10 secret from parsing the proof secret that describes the spending conditions
* of the proof.
* @example
* ```json
* ["P2PK", {
* "nonce": "859d4935c4907062a6297cf4e663e2835d90d97ecdd510745d32f6816323a41f",
* "data": "0249098aa8b9d2fbec49ff8598feb17b592b986e62319a4fa488a3dc36387157a7",
* "tags": [["sigflag", "SIG_INPUTS"]]
* }]
* ```
*/
export type ParsedNUT10Secret = [WellKnownSecretKind, NUT10SecretData];

/**
* 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 = {
/**
* well-known secret kind
* @example "P2PK"
*/
kind: WellKnownSecretKind;
/**
* Expresses the spending condition specific to each kind
* @example "0249098aa8b9d2fbec49ff8598feb17b592b986e62319a4fa488a3dc36387157a7"
*/
data: string;
/**
* A unique random string
* @example "859d4935c4907062a6297cf4e663e2835d90d97ecdd510745d32f6816323a41f"
*/
nonce: string;
/**
* Hold additional data committed to and can be used for feature extensions
* @example [["sigflag", "SIG_INPUTS"]]
*/
tags?: NUT10SecretTag[];
};

/**
* 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;
gudnuf marked this conversation as resolved.
Show resolved Hide resolved

/**
* 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' };
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",
jbojcic1 marked this conversation as resolved.
Show resolved Hide resolved
"@types/express": "5.0.0",
"@types/react": "18.3.12",
"@types/react-dom": "18.3.1",
Expand Down
33 changes: 33 additions & 0 deletions test/cashu/proof.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { describe, expect, test } from 'bun:test';
import type { Proof } from '@cashu/cashu-ts';
import { getP2PKPubkeyFromProofs } from '~/lib/cashu';

describe('getP2PKPubkeyFromProofs', () => {
const proofWithP2PKSecret: Proof = {
amount: 1,
secret:
'["P2PK",{"nonce":"0","data":"0249098aa8b9d2fbec49ff8598feb17b592b986e62319a4fa488a3dc36387157a7","tags":[["sigflag","SIG_INPUTS"]]}]',
C: '02698c4e2b5f9534cd0687d87513c759790cf829aa5739184a3e3735471fbda904',
id: '009a1f293253e41e',
};

test('proof with P2PK secret should return the pubkey', () => {
expect(getP2PKPubkeyFromProofs([proofWithP2PKSecret])).toBe(
'0249098aa8b9d2fbec49ff8598feb17b592b986e62319a4fa488a3dc36387157a7',
);
});

const proof2WithDifferentPubkey: Proof = {
amount: 1,
secret:
'["P2PK",{"nonce":"0","data":"0249098aa8b9d2fbec49ff8598feb17b592b986e62319a4fa488a3dc36387157a8","tags":[["sigflag","SIG_INPUTS"]]}]',
C: '02698c4e2b5f9534cd0687d87513c759790cf829aa5739184a3e3735471fbda904',
id: '009a1f293253e41e',
};

test('proofs with different pubkeys should throw', () => {
expect(() =>
getP2PKPubkeyFromProofs([proofWithP2PKSecret, proof2WithDifferentPubkey]),
).toThrow();
});
});
19 changes: 19 additions & 0 deletions test/cashu/secret.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { describe, expect, test } from 'bun:test';
import { parseSecret } from '~/lib/cashu/secret';

describe('parseSecret', () => {
jbojcic1 marked this conversation as resolved.
Show resolved Hide resolved
test('should return a secret as described in NUT00', () => {
gudnuf marked this conversation as resolved.
Show resolved Hide resolved
const s = parseSecret(
'859d4935c4907062a6297cf4e663e2835d90d97ecdd510745d32f6816323a41f',
);
expect(s).toBe(
'859d4935c4907062a6297cf4e663e2835d90d97ecdd510745d32f6816323a41f',
);
});

test('should throw if secret is not in WELL_KNOWN_SECRET_KINDS', () => {
expect(() =>
parseSecret('["HTLC",{"nonce":"0","data":"0","tags":[]}]'),
).toThrow();
});
});
2 changes: 1 addition & 1 deletion tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
],
"compilerOptions": {
"lib": ["DOM", "DOM.Iterable", "ES2022"],
"types": ["@remix-run/node", "vite/client"],
"types": ["@remix-run/node", "vite/client", "bun-types"],
"isolatedModules": true,
"esModuleInterop": true,
"jsx": "react-jsx",
Expand Down
Loading