diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..07f2bf91 --- /dev/null +++ b/.github/workflows/ci.yml @@ -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 \ No newline at end of file diff --git a/bun.lockb b/bun.lockb index 09633f5c..a2e527ac 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/lib/cashu/index.ts b/lib/cashu/index.ts new file mode 100644 index 00000000..a04b8d1a --- /dev/null +++ b/lib/cashu/index.ts @@ -0,0 +1,2 @@ +export * from './proof'; +export * from './secret'; diff --git a/lib/cashu/proof.ts b/lib/cashu/proof.ts new file mode 100644 index 00000000..afba6e89 --- /dev/null +++ b/lib/cashu/proof.ts @@ -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; +}; diff --git a/lib/cashu/secret.ts b/lib/cashu/secret.ts new file mode 100644 index 00000000..8caf229b --- /dev/null +++ b/lib/cashu/secret.ts @@ -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; + +const NUT10SecretDataSchema = z.object({ + nonce: z.string(), + data: z.string(), + tags: z.array(NUT10SecretTagSchema).optional(), +}) satisfies z.ZodType; + +const WellKnownSecretKindSchema = z.enum(WELL_KNOWN_SECRET_KINDS); + +const NUT10SecretSchema = z.tuple([ + WellKnownSecretKindSchema, + NUT10SecretDataSchema, +]) satisfies z.ZodType; + +/** + * 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; +}; diff --git a/lib/cashu/types.ts b/lib/cashu/types.ts new file mode 100644 index 00000000..6fb0e00d --- /dev/null +++ b/lib/cashu/types.ts @@ -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`: determines whether outputs have to be signed as well + * - `n_sigs`: specifies the minimum number of valid signatures expected + * - `pubkeys`: are additional public keys that can provide signatures (allows multiple entries) + * - `locktime`: is the Unix timestamp of when the lock expires + * - `refund`: 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' }; diff --git a/package.json b/package.json index c256ff75..e619c982 100644 --- a/package.json +++ b/package.json @@ -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", @@ -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", diff --git a/test/cashu/proof.test.ts b/test/cashu/proof.test.ts new file mode 100644 index 00000000..8f8eb159 --- /dev/null +++ b/test/cashu/proof.test.ts @@ -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(); + }); +}); diff --git a/test/cashu/secret.test.ts b/test/cashu/secret.test.ts new file mode 100644 index 00000000..bb37a294 --- /dev/null +++ b/test/cashu/secret.test.ts @@ -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(); + }); +}); diff --git a/tsconfig.json b/tsconfig.json index 9d87dd37..064e82d8 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -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",