Skip to content

Commit

Permalink
create cashu lib with bun tests and ci
Browse files Browse the repository at this point in the history
  • Loading branch information
gudnuf committed Dec 22, 2024
1 parent df16856 commit a17ee6e
Show file tree
Hide file tree
Showing 10 changed files with 334 additions and 1 deletion.
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
Binary file modified bun.lockb
Binary file not shown.
2 changes: 2 additions & 0 deletions 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 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[]) => {
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 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);
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 lib/cashu/types.ts
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)
* - `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
* 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[];
};

/**
* 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;

/**
* 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' };
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
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', () => {
test('should return a secret as described in NUT00', () => {
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

0 comments on commit a17ee6e

Please sign in to comment.