feat(docker): add dev.sh with auto-funding and ATM app setup

- Add dev.sh script for managing regtest development environment
- Implement cmd_fund to fund ATM app owner via Lightning.Pub API
- Add --fund flag to cmd_up for automatic funding on startup
- Update setup_atm_app to write VITE_APP_ID to machine .env
- Fix Electron IPC to pass appId and extensionApiUrl to renderer
- Restructure repo from nested lamassu-next/ to root

The dev.sh script now supports:
- ./dev.sh up --fund  # Start regtest and auto-fund ATM
- ./dev.sh fund       # Fund existing ATM app
- ./dev.sh status     # Show environment status
- ./dev.sh reset      # Clean restart

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Patrick Mulligan 2026-02-15 14:19:16 -05:00
commit c98f126ba7
180 changed files with 2695 additions and 9587 deletions

View file

@ -0,0 +1,46 @@
import { describe, it, expect } from 'vitest'
import { generateIdentity } from '../identity.js'
import { encryptContent, decryptContent, decryptJSON } from '../encryption.js'
describe('encryption', () => {
describe('encryptContent / decryptContent', () => {
it('should encrypt and decrypt string content', () => {
const sender = generateIdentity()
const recipient = generateIdentity()
const message = 'Hello, Nostr!'
const encrypted = encryptContent(sender, recipient.publicKey, message)
expect(encrypted).not.toBe(message)
expect(typeof encrypted).toBe('string')
const decrypted = decryptContent(recipient, sender.publicKey, encrypted)
expect(decrypted).toBe(message)
})
it('should encrypt and decrypt object content', () => {
const sender = generateIdentity()
const recipient = generateIdentity()
const data = { amount: 1000, currency: 'USD', timestamp: Date.now() }
const encrypted = encryptContent(sender, recipient.publicKey, data)
const decrypted = decryptContent(recipient, sender.publicKey, encrypted)
expect(JSON.parse(decrypted)).toEqual(data)
})
})
describe('decryptJSON', () => {
it('should decrypt and parse JSON directly', () => {
const sender = generateIdentity()
const recipient = generateIdentity()
const data = { test: true, nested: { value: 42 } }
const encrypted = encryptContent(sender, recipient.publicKey, data)
const decrypted = decryptJSON<typeof data>(recipient, sender.publicKey, encrypted)
expect(decrypted).toEqual(data)
})
})
})

View file

@ -0,0 +1,82 @@
import { describe, it, expect } from 'vitest'
import { generateIdentity } from '../identity.js'
import {
createSignedEvent,
createMachineStatusEvent,
createAuthEvent,
validateEvent,
generateTxId,
} from '../events.js'
import { LamassuEventKind, type MachineStatus } from '../types.js'
describe('events', () => {
describe('createSignedEvent', () => {
it('should create a properly signed event', () => {
const identity = generateIdentity()
const event = createSignedEvent(identity, {
kind: 1,
content: 'test',
tags: [],
created_at: Math.floor(Date.now() / 1000),
})
expect(event.pubkey).toBe(identity.publicKey)
expect(event.kind).toBe(1)
expect(event.content).toBe('test')
expect(event.id).toMatch(/^[0-9a-f]{64}$/)
expect(event.sig).toMatch(/^[0-9a-f]{128}$/)
})
})
describe('createMachineStatusEvent', () => {
it('should create encrypted status event', () => {
const machine = generateIdentity()
const operator = generateIdentity()
const status: MachineStatus = {
online: true,
lastTransaction: Date.now(),
cashLevels: {
validator: 1000,
dispenser: [{ denomination: 20, count: 100, capacity: 500 }],
},
errors: [],
version: '1.0.0',
}
const event = createMachineStatusEvent(machine, operator.publicKey, status)
expect(event.kind).toBe(LamassuEventKind.MachineStatus)
expect(event.pubkey).toBe(machine.publicKey)
expect(event.tags).toContainEqual(['d', 'status'])
expect(event.tags).toContainEqual(['p', operator.publicKey])
// Content should be encrypted (not readable JSON)
expect(() => JSON.parse(event.content)).toThrow()
})
})
describe('createAuthEvent', () => {
it('should create NIP-42 auth event', () => {
const identity = generateIdentity()
const relayUrl = 'wss://relay.test.com'
const challenge = 'random-challenge-string'
const event = createAuthEvent(identity, relayUrl, challenge)
expect(event.kind).toBe(LamassuEventKind.Auth)
expect(event.content).toBe('')
expect(event.tags).toContainEqual(['relay', relayUrl])
expect(event.tags).toContainEqual(['challenge', challenge])
})
})
describe('generateTxId', () => {
it('should generate unique IDs', () => {
const ids = new Set<string>()
for (let i = 0; i < 100; i++) {
ids.add(generateTxId())
}
expect(ids.size).toBe(100)
})
})
})

View file

@ -0,0 +1,61 @@
import { describe, it, expect } from 'vitest'
import {
generateIdentity,
loadIdentityFromNsec,
exportToNsec,
parsePublicKey,
} from '../identity.js'
describe('identity', () => {
describe('generateIdentity', () => {
it('should generate a valid identity', () => {
const identity = generateIdentity()
expect(identity.privateKey).toBeInstanceOf(Uint8Array)
expect(identity.privateKey.length).toBe(32)
expect(identity.publicKey).toMatch(/^[0-9a-f]{64}$/)
expect(identity.npub).toMatch(/^npub1[a-z0-9]{58}$/)
})
it('should generate unique identities', () => {
const id1 = generateIdentity()
const id2 = generateIdentity()
expect(id1.publicKey).not.toBe(id2.publicKey)
})
})
describe('exportToNsec / loadIdentityFromNsec', () => {
it('should round-trip identity through nsec', () => {
const original = generateIdentity()
const nsec = exportToNsec(original)
expect(nsec).toMatch(/^nsec1[a-z0-9]{58}$/)
const restored = loadIdentityFromNsec(nsec)
expect(restored.publicKey).toBe(original.publicKey)
expect(restored.npub).toBe(original.npub)
})
})
describe('parsePublicKey', () => {
it('should parse hex public key', () => {
const identity = generateIdentity()
const parsed = parsePublicKey(identity.publicKey)
expect(parsed).toBe(identity.publicKey)
})
it('should parse npub', () => {
const identity = generateIdentity()
const parsed = parsePublicKey(identity.npub)
expect(parsed).toBe(identity.publicKey)
})
it('should throw on invalid format', () => {
expect(() => parsePublicKey('invalid')).toThrow()
})
})
})