diff --git a/.env.example b/.env.example index e7ccc412..12753019 100644 --- a/.env.example +++ b/.env.example @@ -6,6 +6,8 @@ AUTO_LINK_ALBY_ACCOUNT=false # Optionally set LDK debug log level to get more info #LDK_LOG_LEVEL=5 +# Optionally set Bark debug log level to get more info +#BARK_LOG_LEVEL=5 # Optionally set Main application debug log level to get more info #LOG_LEVEL=5 @@ -40,4 +42,13 @@ FRONTEND_URL=http://localhost:5173 # Boltz API #BOLTZ_API=https://api.testnet.boltz.exchange -#NETWORK=testnet \ No newline at end of file +#NETWORK=testnet + +# CLN Backend +#LN_BACKEND_TYPE=CLN +# CLN's grpc-host:grpc-port +#CLN_ADDRESS=127.0.0.1:9737 +# CLN's lightning directory containing the grpc certificates, usually ~/.lightning// +#CLN_LIGHTNING_DIR=/path/to/.lightning/bitcoin +# CLN's hold plugin https://github.com/BoltzExchange/hold gRPC address +#CLN_ADDRESS_HOLD=127.0.0.1:9738 diff --git a/.github/dependabot.yaml b/.github/dependabot.yaml index af77982e..200ceb0f 100644 --- a/.github/dependabot.yaml +++ b/.github/dependabot.yaml @@ -4,6 +4,8 @@ updates: directory: / schedule: interval: weekly + ignore: + - dependency-name: google.golang.org/grpc - package-ecosystem: npm directory: /frontend schedule: diff --git a/.github/workflows/build-docker.yaml b/.github/workflows/build-docker.yaml index ae103bb7..614c6dfc 100644 --- a/.github/workflows/build-docker.yaml +++ b/.github/workflows/build-docker.yaml @@ -9,6 +9,9 @@ jobs: TAG: ${{ github.ref_name }} runs-on: ubuntu-22.04 steps: + # see https://github.com/orgs/community/discussions/25678#discussioncomment-5242449 + - name: Delete huge unnecessary tools folder + run: rm -rf /opt/hostedtoolcache - uses: actions/checkout@v4 name: Check out code - name: Install Go diff --git a/.github/workflows/http.yml b/.github/workflows/http.yml index 9d01e13d..8353152d 100644 --- a/.github/workflows/http.yml +++ b/.github/workflows/http.yml @@ -25,6 +25,7 @@ on: jobs: build: strategy: + fail-fast: false matrix: build: [ @@ -85,7 +86,7 @@ jobs: - name: Setup NodeJS uses: actions/setup-node@v4 with: - node-version: "20.x" + node-version: "22.x" - name: Run tests run: mkdir frontend/dist && touch frontend/dist/tmp && go test ./... @@ -128,7 +129,15 @@ jobs: run: go build ${{ env.GOTAGS }} -o build/bin/${{ env.PACKAGE_NAME }}/bin/${{ env.EXEC_NAME }} -ldflags "-X 'github.com/getAlby/hub/version.Tag=${{ env.TAG }}'" cmd/http/main.go - name: Import Code-Signing Certificates for macOS - if: runner.os == 'macOS' + if: >- + runner.os == 'macOS' && + ( + !github.event.pull_request || + ( + github.event.pull_request.head.repo.full_name == github.repository && + github.actor != 'dependabot[bot]' + ) + ) uses: Apple-Actions/import-codesign-certs@v3 with: # The certificates in a PKCS12 file encoded as a base64 string @@ -165,7 +174,15 @@ jobs: shell: bash - name: Sign the MacOS binary and libraries - if: runner.os == 'macOS' + if: >- + runner.os == 'macOS' && + ( + !github.event.pull_request || + ( + github.event.pull_request.head.repo.full_name == github.repository && + github.actor != 'dependabot[bot]' + ) + ) run: | /usr/bin/codesign -s "Developer ID Application: Alby Inc." -f -v --deep --timestamp --options runtime ./build/bin/${{ env.PACKAGE_NAME }}/bin/${{ env.EXEC_NAME }} /usr/bin/codesign -s "Developer ID Application: Alby Inc." -f -v --deep --timestamp --options runtime ./build/bin/${{ env.PACKAGE_NAME }}/lib/*.dylib @@ -189,7 +206,16 @@ jobs: cd ../../.. - name: Notarize the zip file - if: runner.os == 'macOS' && inputs.build-release + if: >- + runner.os == 'macOS' && + inputs.build-release && + ( + !github.event.pull_request || + ( + github.event.pull_request.head.repo.full_name == github.repository && + github.actor != 'dependabot[bot]' + ) + ) run: | echo "Notarizing Zip Files" gon -log-level=info -log-json ./build/darwin/http/gon-notarize.json diff --git a/.github/workflows/linting.yml b/.github/workflows/linting.yml index 5bd519ad..ee2e75c7 100644 --- a/.github/workflows/linting.yml +++ b/.github/workflows/linting.yml @@ -18,7 +18,7 @@ jobs: - uses: actions/checkout@v3 - uses: actions/setup-node@v3 with: - node-version: 20.x + node-version: 22.x cache: "yarn" cache-dependency-path: frontend/yarn.lock diff --git a/.github/workflows/wails.yml b/.github/workflows/wails.yml index f99301b0..0cf8acca 100644 --- a/.github/workflows/wails.yml +++ b/.github/workflows/wails.yml @@ -73,10 +73,10 @@ jobs: - name: Setup NodeJS uses: actions/setup-node@v4 with: - node-version: "20.x" + node-version: "22.x" - name: Install Wails - run: go install github.com/wailsapp/wails/v2/cmd/wails@v2.10.2 + run: go install github.com/wailsapp/wails/v2/cmd/wails@v2.14.0 shell: bash - name: Install Linux Wails deps @@ -129,7 +129,15 @@ jobs: shell: bash - name: Import Code-Signing Certificates for macOS - if: runner.os == 'macOS' + if: >- + runner.os == 'macOS' && + ( + !github.event.pull_request || + ( + github.event.pull_request.head.repo.full_name == github.repository && + github.actor != 'dependabot[bot]' + ) + ) uses: Apple-Actions/import-codesign-certs@v3 with: # The certificates in a PKCS12 file encoded as a base64 string @@ -190,7 +198,15 @@ jobs: mv ./build/out/${{ env.PACKAGE_NAME }}.tar.bz2 ./build/bin/ - name: Sign the macOS binary - if: runner.os == 'macOS' + if: >- + runner.os == 'macOS' && + ( + !github.event.pull_request || + ( + github.event.pull_request.head.repo.full_name == github.repository && + github.actor != 'dependabot[bot]' + ) + ) run: | echo "Signing Package" /usr/bin/codesign -s "Developer ID Application: Alby Inc." -f -v --deep --timestamp --options runtime --entitlements ./build/darwin/entitlements.plist "./build/bin/${{ env.EXEC_NAME }}.app" @@ -222,7 +238,15 @@ jobs: EOF - name: Notarize the DMG image - if: runner.os == 'macOS' + if: >- + runner.os == 'macOS' && + ( + !github.event.pull_request || + ( + github.event.pull_request.head.repo.full_name == github.repository && + github.actor != 'dependabot[bot]' + ) + ) run: | echo "Notarizing Zip Files" gon -log-level=info -log-json ./build/darwin/gon-notarize.json diff --git a/.gitignore b/.gitignore index 1b519bd7..7cfd951f 100644 --- a/.gitignore +++ b/.gitignore @@ -30,4 +30,5 @@ glalby *.db-shm *.db-wal *.db-journal -albyhub-data \ No newline at end of file +albyhub-data +.claude/worktrees \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..e99b0d33 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,232 @@ +# AGENTS.md + +This file provides guidance for AI coding agents working on the Alby Hub repository. + +## Project Overview + +Alby Hub is a self-custodial **Nostr Wallet Connect (NWC)** service that bridges Lightning Network wallets with applications supporting the NIP-47 protocol. It supports multiple Lightning backends (LDK, LND, Phoenixd, Cashu) and runs as either a web server or a desktop app (via Wails). + +## Tech Stack + +- **Backend:** Go 1.25, Echo v4, GORM, SQLite (default) / PostgreSQL +- **Frontend:** React 18, TypeScript, Vite, Tailwind CSS 4, shadcn/ui, Radix UI, Zustand, SWR +- **Desktop:** Wails v2 (produces native desktop app using Go + web frontend) +- **Lightning:** LDK (embedded), LND (gRPC), Phoenixd, Cashu +- **Protocol:** Nostr NIP-47 (Nostr Wallet Connect) + +## Project Structure + +```text +hub/ +├── api/ # HTTP API handlers and request/response models +├── alby/ # Alby account integration (OAuth, backups) +├── apps/ # App connection management +├── cmd/http/main.go # HTTP server entry point +├── config/ # Configuration management +├── db/ # Database layer, migrations, queries +├── events/ # Event pub/sub system +├── frontend/ # React frontend (see below) +├── http/ # HTTP service router +├── lnclient/ # LN abstraction interface + implementations +│ ├── ldk/ # Embedded LDK node +│ ├── lnd/ # LND gRPC client +│ ├── phoenixd/ # Phoenixd client +│ └── cashu/ # Cashu client +├── nip47/ # NIP-47 protocol implementation +│ ├── controllers/ # Per-method request handlers +│ ├── permissions/ # Permission validation +│ └── cipher/ # NIP-04 encryption +├── service/ # Core service orchestration +├── swaps/ # Boltz atomic swap integration +├── transactions/ # Transaction tracking and metadata +├── tests/ # Test helpers and utilities +└── wails/ # Wails desktop-specific handlers +``` + +### Frontend Structure + +```text +frontend/src/ +├── components/ # Reusable UI components +├── screens/ # Page-level route components +├── contexts/ # React context providers +├── hooks/ # Custom React hooks +├── state/ # Zustand client state stores +├── lib/ # Auth, backend type helpers +├── utils/ # Shared utilities (request.ts, swr.ts, formatting, etc.) +├── types.ts # Shared TypeScript types +└── routes.tsx # Route definitions + +frontend/platform_specific/ +├── http/ # Web-specific utilities (copied at build time) +└── wails/ # Desktop-specific utilities (copied at build time) +``` + +## Development Setup + +### Prerequisites + +- Node.js 20+ +- Yarn + +### Running in HTTP Mode (Primary) + +```bash +# Terminal 1 – Frontend (port 5173) +cd frontend +yarn install +yarn dev:http + +# Terminal 2 – Backend (port 8080) +cp .env.example .env # configure as needed +go run cmd/http/main.go +``` + +### Running in Desktop Mode (Wails) + +```bash +wails dev -tags "wails" +``` + +**Wails versions must stay in sync:** the `github.com/wailsapp/wails/v2` version in `go.mod` and the Wails CLI version installed in `.github/workflows/wails.yml` (`go install ...cmd/wails@vX.Y.Z`) must match. When bumping one, always update the other — this is a common source of drift (e.g. via Dependabot updates to `go.mod` only). + +## Testing + +### Go Backend + +```bash +# Run all tests +go test ./... + +# Run specific test by name +go test ./... -run TestHandleGetInfoEvent + +# Run with PostgreSQL (optional) +export TEST_DATABASE_URI="postgresql://user:password@localhost:5432/postgres" +go test ./... +``` + +Mocks are generated with `mockery` (config in `.mockery.yaml`); run it after changing any interface. + +### Frontend + +```bash +cd frontend +yarn lint # ESLint + TypeScript type check + Prettier +yarn tsc:compile # TypeScript only +yarn format # Prettier only +``` + +No Jest/Vitest tests exist; frontend quality is enforced via linting. + +## Building + +```bash +# HTTP production build +cd frontend && yarn build:http +go build -o main cmd/http/main.go + +# Docker +docker build . -t albyhub:latest +``` + +## Key Architecture Patterns + +### Request Flow + +```text +HTTP Request / NIP-47 Nostr Event + → HTTP Handler / NIP-47 Event Handler + → api/ package (business logic) + → LNClient interface + → Backend implementation (LDK/LND/Phoenixd/Cashu) +``` + +### Event System + +Services communicate via `events/` pub/sub. Prefer publishing events over direct inter-service calls. Key events use the `nwc_*` prefix (e.g., `nwc_payment_sent`, `nwc_payment_received`). + +### Platform-Specific Frontend Code + +Code under `frontend/platform_specific/http/` and `frontend/platform_specific/wails/` is swapped at build time. Any platform-specific frontend logic must have both variants. + +### LNClient Interface + +`lnclient/models.go` defines the interface all backends must implement. Changes to this interface require updates to all four implementations (LDK, LND, Phoenixd, Cashu) and their mocks. + +## Database + +- **Migrations:** `db/migrations/` — always add new migrations here; never modify existing ones. +- **SQLite:** WAL mode, 5s busy timeout, 20MB cache. Default for development and most deployments. +- **PostgreSQL:** Supported for production. If touching DB code, test with both. +- **ORM:** GORM with `go-gormigrate`. Use GORM conventions for new models. + +## Coding Conventions + +### Go + +- Idiomatic Go; `gofmt` formatting expected. +- Structured logging via `logrus` with contextual fields — no `fmt.Print`. +- Error wrapping with `fmt.Errorf("context: %w", err)` for debugging. +- Use the event publisher for cross-service communication. +- New API endpoints belong in `api/api.go` with corresponding HTTP routes in `http/http_service.go`. + +### TypeScript / React + +- **Avoid using useNavigate** — Use component where possible to ensure good browser UX. +- **Use shadcn/ui components** for all UI — do not create custom components unless no shadcn equivalent exists. +- **Do not modify core shadcn/ui components** — customize behavior by composing or wrapping them, not by editing the source files directly. +- **Prefer Tailwind utility classes** over custom `px` definitions or inline styles. Use Tailwind's spacing, sizing, and layout utilities instead of hardcoded pixel values. +- **Never use `!important` Tailwind modifiers** (e.g., `!px-12`, `!text-sm`). If a component's default styles need overriding, use a proper variant, compose with a wrapper, or extend the component — don't force specificity with `!`. +- **Use the theme system** for colors, border-radius, shadows, and other design tokens. Reference CSS variables / Tailwind theme tokens (e.g., `bg-primary`, `rounded-lg`, `shadow-sm`) rather than hardcoding hex values or arbitrary values. See `frontend/src/index.css` for available theme variables. +- **Keep layouts flat** — avoid nesting cards inside cards or wrapping elements in unnecessary bordered containers. Prefer clear, flat visual hierarchy. +- **Match existing spacing patterns** — before adding new components, check sibling components for consistent padding, margins, and gaps. Ensure sibling elements have equal dimensions where appropriate. +- **Write copy from the user's perspective** — Alby Hub IS the wallet; don't explain what a lightning wallet is or tell the user to "connect to a wallet" when they're already inside one. Keep UI copy concise and use the product's own vocabulary (sats, connections, apps). +- **Use the `*Icon` suffix when importing lucide-react icons** (e.g., `ZapIcon`, `BitcoinIcon`, `ArrowDownIcon`) — both forms are valid lucide exports, but this codebase consistently uses the suffixed alias. Don't mix styles. +- Strict TypeScript — no `any` types. +- Functional components with hooks only. +- SWR for server state; Zustand for client state (stores in `frontend/src/state/`). +- HTTP requests use the typed `request()` helper in `frontend/src/utils/request.ts`. +- New screens added to `frontend/src/routes.tsx`. +- ESLint + Prettier enforced via pre-commit hooks (husky). + +### Branches + +Use a type prefix followed by a short, dash-separated summary: `feat/`, `chore/`, or `fix/`. For example: + +```text +feat/add-cashu-backend +fix/payment-timeout-crash +chore/bump-go-1.25 +``` + +### Commits + +Follow **Conventional Commits** format (`feat:`, `fix:`, `chore:`, etc.) — enforced by commitlint. + +## Critical Files + +| File | Purpose | +|------|---------| +| `cmd/http/main.go` | HTTP server entry point | +| `main_wails.go` | Desktop entry point | +| `api/api.go` | Primary API endpoint handlers | +| `service/service.go` | Core service initialization | +| `service/start.go` | Service startup sequence | +| `lnclient/models.go` | LNClient interface definition | +| `nip47/event_handler.go` | NIP-47 request dispatch | +| `db/migrations/` | Database schema history | +| `frontend/src/types.ts` | Shared TypeScript types | +| `frontend/src/routes.tsx` | Frontend routing | + +## Security Considerations + +- **Seed phrases** are AES-encrypted at rest; decrypted in-memory only when the LN node is running. +- **NIP-47 messages** use NIP-04 or NIP-44 v2 encryption per app keypair (NIP-44 v2 preferred; NIP-04 is the fallback default). +- **API authentication** uses JWT (golang-jwt v5). +- Never log sensitive data (seeds, macaroons, tokens). +- Validate all user input at system boundaries; trust internal service calls. + +## CI/CD + +CI runs Go tests (including PostgreSQL), frontend lint/type checks, and binary builds for Linux and macOS. All checks must pass before merging to `master`. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 120000 index 00000000..47dc3e3d --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 805aa282..68cfcdae 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM node:20-alpine AS frontend +FROM node:22-alpine AS frontend # Set the base path for the frontend build # This can be overridden at build time with --build-arg BASE_PATH= e.g. --build-arg BASE_PATH=/hub @@ -9,7 +9,7 @@ COPY frontend ./frontend RUN echo "Building frontend with base path $BASE_PATH" RUN cd frontend && yarn install --network-timeout 3000000 && yarn build:http -FROM golang:1.24 AS builder +FROM golang:1.26.2 AS builder ARG TARGETPLATFORM ARG BUILDPLATFORM diff --git a/README.md b/README.md index d0ba2cdf..a80cdc04 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ The application can run in two modes: Ideally the app runs 24/7 (on a node, VPS or always-online desktop/laptop machine) so it can be connected to a lightning address and receive online payments. -## Run on Alby Cloud +## Learn more about Alby Hub Visit [albyhub.com](https://albyhub.com) to learn more and get started and get Alby Hub running in minutes. @@ -29,6 +29,7 @@ By default Alby Hub uses the embedded LDK based lightning node. Optionally it ca - LND - Phoenixd - Cashu +- CLN - want more? please open an issue. ## Development @@ -59,7 +60,7 @@ By default Alby Hub uses the embedded LDK based lightning node. Optionally it ca Go to `/frontend` 1. `yarn install` -2. `yarn dev` +2. `yarn dev:http` ### HTTP Production build @@ -157,7 +158,7 @@ For more information on the Go pprof library, see the [official documentation](h The following configuration options can be set as environment variables or in a .env file -- `RELAY`: default: "wss://relay.getalby.com/v1" (can support multiple separated by commas) +- `RELAY`: default: "wss://relay.getalby.com,wss://relay2.getalby.com" (supports multiple separated by commas) - `DATABASE_URI`: A sqlite filename or postgres URL. Default is SQLite DB `nwc.db` without a path, which will be put in the user home directory: $XDG_DATA_HOME/albyhub/nwc.db - `PORT`: The port on which the app should listen on (default: 8080) - `WORK_DIR`: Directory to store NWC data files. Default: $XDG_DATA_HOME/albyhub @@ -206,13 +207,33 @@ Migration of the database is currently experimental. Please make a backup before go run cmd/db_migrate/main.go -from .data/nwc.db -to postgresql://myuser:mypass@localhost:5432/nwc +#### Migration from Postgres to Sqlite + +No manual steps are needed: create a migration file from Settings -> Migrate Alby Hub. The contents of the Postgres database will automatically be copied into a Sqlite database which is included in the migration file. Alternatively, run the migration tool manually: + +go run cmd/db_migrate/main.go -from postgresql://myuser:mypass@localhost:5432/nwc -to .data/nwc.db + ## Node-specific backend parameters - `ENABLE_ADVANCED_SETUP`: set to `false` to force a specific backend type (combined with backend parameters below) +### CLN Backend parameters + +Can be configured via env or the UI + +- `LN_BACKEND_TYPE`: CLN +- `CLN_ADDRESS`: the CLN grpc address (grpc-host and grpc-port), e.g. `127.0.0.1:9737` +- `CLN_LIGHTNING_DIR`: CLN's lightning directory containing the grpc certificates, usually `~/.lightning/` + +Optional for hold invoice methods support: + +- `CLN_ADDRESS_HOLD`: the CLN hold plugin grpc address (grpc-host and grpc-port), e.g. `127.0.0.1:9738` + +If you are copying the certificates to another machine make sure you get the `ca.pem`, `client.pem` and `client-key.pem` from the lightning directory and optionally from the `hold` directory inside the lightning directory and keep the sub-directory structure of the hold directory. + ### LND Backend parameters -Currently only LND can be configured via env. Other node types must be configured via the UI. +LND can be configured via env. Other node types may need to be configured via the UI. _To configure via env, the following parameters must be provided:_ @@ -230,6 +251,8 @@ _To configure via env, the following parameters must be provided:_ - `LDK_MAX_CHANNEL_SATURATION`: Sets the maximum portion of a channel's total capacity that may be used for sending a payment, expressed as a power of 1/2. See `max_channel_saturation_power_of_half` in [LDK docs](https://docs.rs/lightning/latest/lightning/routing/router/struct.PaymentParameters.html#structfield.max_channel_saturation_power_of_half). - `LDK_MAX_PATH_COUNT`: Maximum number of paths that may be used by MPP payments. - `LDK_LOG_LEVEL`: Log level for the LDK node. Higher is more verbose. Default: 3. This is separate from the main application log level, allowing you to enable more verbose LDK logging (e.g., level 4, 5 or 6) without enabling verbose logging for the entire application. +- `LDK_CHANNEL_MONITOR_WARNING_SIZE_BYTES`: If a channel monitor is larger than this value, a performance warning will be shown on the node page. +- `LDK_LSPS2_ADDRESSES`: Override the LSPS2 just-in-time (JIT) LSP provider for receiving. When set, Alby Hub can receive payments even without inbound liquidity: the configured LSP opens a channel on the fly and the fee is deducted from the incoming payment. Expected format is a single `@:`. When set, the "Open Your First Channel" prompts are hidden since the first channel is created automatically on the first receive. #### LDK Network Configuration @@ -244,6 +267,12 @@ _To configure via env, the following parameters must be provided:_ - `LDK_ELECTRUM_SERVER=electrum.mutinynet.com:50001` +##### Signet + +- `MEMPOOL_API=https://mempool.space/signet/api` +- `LDK_NETWORK=signet` +- `LDK_ESPLORA_SERVER=https://mempool.space/signet/api` + ##### Testnet (Not recommended - try Mutinynet) - `MEMPOOL_API=https://mempool.space/testnet/api` @@ -262,6 +291,16 @@ _To configure via env, the following parameters must be provided:_ See [Phoenixd](scripts/linux-x86_64/phoenixd/README.md) +### Bark + +Bark connects to an [Ark](https://second.tech/) server. It can be configured via env. + +- `LN_BACKEND_TYPE`: BARK +- `BARK_SERVER`: the Ark server URL. For signet use `https://ark.signet.2nd.dev` +- `BARK_ESPLORA_SERVER`: the Esplora server URL used for chain data. For signet use `https://esplora.signet.2nd.dev`. +- `BARK_SERVER_ACCESS_TOKEN`: an optional access token, only required if using a private Ark server. +- `BARK_LOG_LEVEL`: Log level for Bark. Higher is more verbose. Default: 3. This is separate from the main application log level, allowing you to enable more verbose Bark logging (e.g., level 4 or 5) without enabling verbose logging for the entire application. + ### Alby OAuth Create an OAuth client at the [Alby Developer Portal](https://getalby.com/developer) and set your `ALBY_OAUTH_CLIENT_ID` and `ALBY_OAUTH_CLIENT_SECRET` in your .env. If not running locally, you'll also need to change your `BASE_URL`. @@ -363,6 +402,8 @@ Once the user has authorized the app connection a `nwc:success` message is sent If you need help contact support@getalby.com or reach out on Nostr: npub1getal6ykt05fsz5nqu4uld09nfj3y3qxmv8crys4aeut53unfvlqr80nfm You can also visit the chat of our Community on [Telegram](https://t.me/getalby). +For security vulnerabilities, please follow our [security policy](SECURITY.md). + ## ⚡️Donations Want to support the work on Alby? diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 00000000..790ba2be --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,7 @@ +# Security Policy + +## Reporting a Vulnerability + +Please report suspected security vulnerabilities privately by emailing [security@getalby.com](mailto:security@getalby.com). Do not open a public issue or disclose the vulnerability publicly until we have coordinated a fix. + +Please include the affected version or component, the potential impact, and clear steps to reproduce the issue. We will acknowledge your report and keep you informed as we investigate and address it. diff --git a/alby/alby_oauth_service.go b/alby/alby_oauth_service.go index 90e338af..c496cc95 100644 --- a/alby/alby_oauth_service.go +++ b/alby/alby_oauth_service.go @@ -61,7 +61,7 @@ func NewAlbyOAuthService(db *gorm.DB, cfg config.Config, keys keys.Keys, eventPu conf := &oauth2.Config{ ClientID: cfg.GetEnv().AlbyClientId, ClientSecret: cfg.GetEnv().AlbyClientSecret, - Scopes: []string{"account:read", "balance:read", "payments:send"}, + Scopes: []string{"account:read"}, Endpoint: oauth2.Endpoint{ TokenURL: albyOAuthAPIURL + "/oauth/token", AuthURL: albyOAuthAuthUrl, @@ -93,7 +93,7 @@ func (svc *albyOAuthService) RemoveOAuthAccessToken() error { return err } -func (svc *albyOAuthService) CallbackHandler(ctx context.Context, code string, lnClient lnclient.LNClient) error { +func (svc *albyOAuthService) CallbackHandler(ctx context.Context, code string) error { token, err := svc.oauthConf.Exchange(ctx, code) if err != nil { logger.Logger.WithError(err).Error("Failed to exchange token") @@ -408,7 +408,7 @@ func (svc *albyOAuthService) DeleteLightningAddress(ctx context.Context, address res, err := client.Do(req) if err != nil { - logger.Logger.WithError(err).Error("Failed to delete lightning address endpoint") + logger.Logger.WithError(err).Error("Failed to request delete lightning address endpoint") return err } @@ -418,6 +418,13 @@ func (svc *albyOAuthService) DeleteLightningAddress(ctx context.Context, address return errors.New("failed to read response body") } + if res.StatusCode == http.StatusNotFound { + // The lightning address was already deleted on the Alby account + // (e.g. removed previously). Treat as success so deletion is idempotent. + logger.Logger.WithField("address", address).Info("Lightning address already deleted on Alby account, ignoring 404") + return nil + } + if res.StatusCode >= 300 { return fmt.Errorf("DELETE request to /internal/lightning_addresses/%s returned non-success status: %d %s", address, res.StatusCode, string(responseBody)) } @@ -525,7 +532,11 @@ func (svc *albyOAuthService) UnlinkAccount(ctx context.Context) error { return nil } -func (svc *albyOAuthService) LinkAccount(ctx context.Context, lnClient lnclient.LNClient, budget uint64, renewal string) error { +func (svc *albyOAuthService) LinkAccount(ctx context.Context, lnClient lnclient.LNClient, budgetSat uint64, renewal string) error { + if lnClient == nil { + return errors.New("LNClient not available") + } + svc.deleteAlbyAccountApps() connectionPubkey, err := svc.createAlbyAccountNWCNode(ctx) @@ -547,7 +558,7 @@ func (svc *albyOAuthService) LinkAccount(ctx context.Context, lnClient lnclient. app, _, err := apps.NewAppsService(svc.db, svc.eventPublisher, svc.keys, svc.cfg).CreateApp( ALBY_ACCOUNT_APP_NAME, connectionPubkey, - budget, + budgetSat, renewal, nil, scopes, @@ -1183,6 +1194,10 @@ func (svc *albyOAuthService) CreateLSPOrder(ctx context.Context, lsp, network st } func (svc *albyOAuthService) RequestAutoChannel(ctx context.Context, lnClient lnclient.LNClient, isPublic bool) (*AutoChannelResponse, error) { + if lnClient == nil { + return nil, errors.New("LNClient not available") + } + nodeInfo, err := lnClient.GetInfo(ctx) if err != nil { logger.Logger.WithError(err).Error("Failed to request own node info", err) @@ -1317,11 +1332,11 @@ func (svc *albyOAuthService) requestAutoChannel(ctx context.Context, url string, } var invoice string - var fee uint64 + var feeSat uint64 if newAutoChannelResponse.Payment != nil { invoice = newAutoChannelResponse.Payment.Bolt11.Invoice - fee, err = strconv.ParseUint(newAutoChannelResponse.Payment.Bolt11.FeeTotalSat, 10, 64) + feeSat, err = strconv.ParseUint(newAutoChannelResponse.Payment.Bolt11.FeeTotalSat, 10, 64) if err != nil { logger.Logger.WithError(err).WithFields(logrus.Fields{ "url": url, @@ -1335,16 +1350,16 @@ func (svc *albyOAuthService) requestAutoChannel(ctx context.Context, url string, return nil, err } - if fee != uint64(paymentRequest.MSatoshi/1000) { + if feeSat != uint64(paymentRequest.MSatoshi/1000) { logger.Logger.WithFields(logrus.Fields{ "invoice_amount": paymentRequest.MSatoshi / 1000, - "fee": fee, + "fee": feeSat, }).WithError(err).Error("Invoice amount does not match LSP fee") return nil, errors.New("invoice amount does not match LSP fee") } } - channelSize, err := strconv.ParseUint(newAutoChannelResponse.LspBalanceSat, 10, 64) + channelSizeSat, err := strconv.ParseUint(newAutoChannelResponse.LspBalanceSat, 10, 64) if err != nil { logger.Logger.WithError(err).WithFields(logrus.Fields{ "url": url, @@ -1353,12 +1368,60 @@ func (svc *albyOAuthService) requestAutoChannel(ctx context.Context, url string, } return &AutoChannelResponse{ - Invoice: invoice, - Fee: fee, - ChannelSize: channelSize, + Invoice: invoice, + Fee: feeSat, + FeeSat: feeSat, + ChannelSize: channelSizeSat, + ChannelSizeSat: channelSizeSat, }, nil } +func (svc *albyOAuthService) GetStories(ctx context.Context) ([]Story, error) { + client := &http.Client{Timeout: 10 * time.Second} + url := fmt.Sprintf("%s/stories", albyInternalAPIURL) + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + logger.Logger.WithError(err).Error("Error creating request to stories endpoint") + return nil, fmt.Errorf("create stories request: %w", err) + } + setDefaultRequestHeaders(req) + + res, err := client.Do(req) + if err != nil { + logger.Logger.WithError(err).Error("Failed to fetch stories from API") + return nil, fmt.Errorf("fetch stories: %w", err) + } + defer res.Body.Close() + + body, err := io.ReadAll(res.Body) + if err != nil { + logger.Logger.WithError(err).WithFields(logrus.Fields{ + "url": url, + }).Error("Failed to read response body") + return nil, fmt.Errorf("read stories response body: %w", err) + } + + if res.StatusCode >= 300 { + logger.Logger.WithFields(logrus.Fields{ + "body": string(body), + "status_code": res.StatusCode, + }).Error("stories endpoint returned non-success code") + return nil, fmt.Errorf("stories endpoint returned %d: %s", res.StatusCode, string(body)) + } + + var stories []Story + if err := json.Unmarshal(body, &stories); err != nil { + logger.Logger.WithFields(logrus.Fields{ + "body": string(body), + "error": err, + }).Error("Failed to decode stories API response") + return nil, fmt.Errorf("decode stories response: %w", err) + } + + return stories, nil +} + func setDefaultRequestHeaders(req *http.Request) { req.Header.Set("Content-Type", "application/json") req.Header.Set("User-Agent", "AlbyHub/"+version.Tag) @@ -1403,5 +1466,7 @@ func getEventWhitelist() []string { // client-side events "payment_failed_details", + "debit_card_url_clicked", + "debit_card_connect", } } diff --git a/alby/alby_service.go b/alby/alby_service.go index 04e9f7ed..6514350d 100644 --- a/alby/alby_service.go +++ b/alby/alby_service.go @@ -9,7 +9,6 @@ import ( "net/http" "time" - "github.com/getAlby/hub/config" "github.com/getAlby/hub/logger" "github.com/sirupsen/logrus" ) @@ -17,19 +16,67 @@ import ( const albyInternalAPIURL = "https://getalby.com/api" type albyService struct { - cfg config.Config } -func NewAlbyService(cfg config.Config) *albyService { - albySvc := &albyService{ - cfg: cfg, - } - return albySvc +func NewAlbyService() *albyService { + return &albyService{} } -func (svc *albyService) GetBitcoinRate(ctx context.Context) (*BitcoinRate, error) { +func (svc *albyService) GetCurrencies(ctx context.Context) ([]Currency, error) { + client := &http.Client{Timeout: 10 * time.Second} + url := fmt.Sprintf("%s/rates", albyInternalAPIURL) + + req, err := http.NewRequestWithContext(ctx, "GET", url, nil) + if err != nil { + logger.Logger.WithError(err).Error("Error creating request to currencies endpoint") + return nil, err + } + setDefaultRequestHeaders(req) + + res, err := client.Do(req) + if err != nil { + logger.Logger.WithError(err).Error("Failed to fetch currencies from API") + return nil, err + } + + defer res.Body.Close() + + body, err := io.ReadAll(res.Body) + if err != nil { + logger.Logger.WithError(err).WithFields(logrus.Fields{ + "url": url, + }).Error("Failed to read response body") + return nil, errors.New("failed to read response body") + } + + if res.StatusCode >= 300 { + logger.Logger.WithFields(logrus.Fields{ + "body": string(body), + "status_code": res.StatusCode, + }).Error("Currencies endpoint returned non-success code") + return nil, fmt.Errorf("currencies endpoint returned non-success code: %s", string(body)) + } + + rawCurrencies := map[string]Currency{} + err = json.Unmarshal(body, &rawCurrencies) + if err != nil { + logger.Logger.WithFields(logrus.Fields{ + "body": string(body), + "error": err, + }).Error("Failed to decode currencies API response") + return nil, err + } + + currencies := []Currency{} + for _, currency := range rawCurrencies { + currencies = append(currencies, currency) + } + + return currencies, nil +} + +func (svc *albyService) GetBitcoinRate(ctx context.Context, currency string) (*BitcoinRate, error) { client := &http.Client{Timeout: 10 * time.Second} - currency := svc.cfg.GetCurrency() url := fmt.Sprintf("%s/rates/%s", albyInternalAPIURL, currency) @@ -123,6 +170,11 @@ func (svc *albyService) GetChannelPeerSuggestions(ctx context.Context) ([]Channe return nil, err } + for i := range suggestions { + suggestions[i].MinimumChannelSizeSat = suggestions[i].MinimumChannelSize + suggestions[i].MaximumChannelSizeSat = suggestions[i].MaximumChannelSize + } + logger.Logger.WithFields(logrus.Fields{"channel_suggestions": suggestions}).Debug("Alby channel peer suggestions response") return suggestions, nil } diff --git a/alby/models.go b/alby/models.go index 046878b1..f51a636f 100644 --- a/alby/models.go +++ b/alby/models.go @@ -9,7 +9,8 @@ import ( type AlbyService interface { GetInfo(ctx context.Context) (*AlbyInfo, error) - GetBitcoinRate(ctx context.Context) (*BitcoinRate, error) + GetBitcoinRate(ctx context.Context, currency string) (*BitcoinRate, error) + GetCurrencies(ctx context.Context) ([]Currency, error) GetChannelPeerSuggestions(ctx context.Context) ([]ChannelPeerSuggestion, error) } @@ -22,8 +23,8 @@ type AlbyOAuthService interface { GetUserIdentifier() (string, error) GetLightningAddress() (string, error) IsConnected(ctx context.Context) bool - LinkAccount(ctx context.Context, lnClient lnclient.LNClient, budget uint64, renewal string) error - CallbackHandler(ctx context.Context, code string, lnClient lnclient.LNClient) error + LinkAccount(ctx context.Context, lnClient lnclient.LNClient, budgetSat uint64, renewal string) error + CallbackHandler(ctx context.Context, code string) error GetMe(ctx context.Context) (*AlbyMe, error) UnlinkAccount(ctx context.Context) error RequestAutoChannel(ctx context.Context, lnClient lnclient.LNClient, isPublic bool) (*AutoChannelResponse, error) @@ -31,6 +32,7 @@ type AlbyOAuthService interface { RemoveOAuthAccessToken() error CreateLightningAddress(ctx context.Context, address string, appId uint) (*CreateLightningAddressResponse, error) DeleteLightningAddress(ctx context.Context, address string) error + GetStories(ctx context.Context) ([]Story, error) } type CreateLightningAddressResponse struct { @@ -48,9 +50,11 @@ type AutoChannelRequest struct { } type AutoChannelResponse struct { - Invoice string `json:"invoice"` - ChannelSize uint64 `json:"channelSize"` - Fee uint64 `json:"fee"` + Invoice string `json:"invoice"` + ChannelSize uint64 `json:"channelSize"` // deprecated + ChannelSizeSat uint64 `json:"channelSizeSat"` + Fee uint64 `json:"fee"` // deprecated + FeeSat uint64 `json:"feeSat"` } type AlbyInfoHub struct { @@ -101,8 +105,10 @@ type ChannelPeerSuggestion struct { PaymentMethod string `json:"paymentMethod"` Pubkey string `json:"pubkey"` Host string `json:"host"` - MinimumChannelSize uint64 `json:"minimumChannelSize"` - MaximumChannelSize uint64 `json:"maximumChannelSize"` + MinimumChannelSize uint64 `json:"minimumChannelSize"` // deprecated + MinimumChannelSizeSat uint64 `json:"minimumChannelSizeSat"` + MaximumChannelSize uint64 `json:"maximumChannelSize"` // deprecated + MaximumChannelSizeSat uint64 `json:"maximumChannelSizeSat"` MaximumChannelExpiryBlocks *uint32 `json:"maximumChannelExpiryBlocks"` Name string `json:"name"` Image string `json:"image"` @@ -113,6 +119,7 @@ type ChannelPeerSuggestion struct { Description string `json:"description"` Note string `json:"note"` PublicChannelsAllowed bool `json:"publicChannelsAllowed"` + NodeAddress string `json:"nodeAddress"` FeeTotalSat1m *uint32 `json:"feeTotalSat1m"` FeeTotalSat2m *uint32 `json:"feeTotalSat2m"` FeeTotalSat3m *uint32 `json:"feeTotalSat3m"` @@ -129,6 +136,13 @@ type LSPChannelOffer struct { LspDescription string `json:"lspDescription"` } +type Currency struct { + IsoCode string `json:"iso_code"` + Symbol string `json:"symbol"` + Name string `json:"name"` + Priority int `json:"priority"` +} + type BitcoinRate struct { Code string `json:"code"` Symbol string `json:"symbol"` @@ -141,6 +155,20 @@ type ErrorResponse struct { Message string `json:"message"` } +type StoryCta struct { + Label string `json:"label"` + URL string `json:"url"` + OpenInNewTab bool `json:"openInNewTab"` +} + +type Story struct { + ID int `json:"id"` + Title string `json:"title"` + Avatar string `json:"avatar"` + VideoID string `json:"videoId,omitempty"` + Cta *StoryCta `json:"cta,omitempty"` +} + type LSPChannelPaymentBolt11 struct { Invoice string `json:"invoice"` FeeTotalSat string `json:"fee_total_sat"` diff --git a/api/api.go b/api/api.go index ba7c26f8..fc53d8e0 100644 --- a/api/api.go +++ b/api/api.go @@ -2,20 +2,28 @@ package api import ( "context" + "crypto/tls" + "crypto/x509" + "encoding/hex" "encoding/json" + "encoding/pem" "errors" "flag" "fmt" "io" "net/http" "net/url" + "os" + "path/filepath" "slices" "strconv" "strings" "sync" + "sync/atomic" "time" "github.com/sirupsen/logrus" + "gopkg.in/macaroon.v2" "gorm.io/datatypes" "gorm.io/gorm" @@ -48,6 +56,9 @@ type api struct { startupError error startupErrorTime time.Time eventPublisher events.EventPublisher + // set after a migration file is created; the hub is halted at that point + // and the frontend should keep showing the migration success page + nodeMigrationFileCreated atomic.Bool } func NewAPI(svc service.Service, gormDB *gorm.DB, config config.Config, keys keys.Keys, albySvc alby.AlbyService, albyOAuthSvc alby.AlbyOAuthService, eventPublisher events.EventPublisher) *api { @@ -72,6 +83,12 @@ func (api *api) CreateApp(createAppRequest *CreateAppRequest) (*CreateAppRespons } } + maxAmountSat := uint64(0) + resolvedMaxAmountSat := ResolveToSat(createAppRequest.MaxAmountSat, createAppRequest.MaxAmountMsat, createAppRequest.MaxAmount, nil) + if resolvedMaxAmountSat != nil { + maxAmountSat = *resolvedMaxAmountSat + } + if createAppRequest.Name == alby.ALBY_ACCOUNT_APP_NAME { return nil, fmt.Errorf("Reserved app name: %s", alby.ALBY_ACCOUNT_APP_NAME) } @@ -90,7 +107,7 @@ func (api *api) CreateApp(createAppRequest *CreateAppRequest) (*CreateAppRespons app, pairingSecretKey, err := api.appsSvc.CreateApp( createAppRequest.Name, createAppRequest.Pubkey, - createAppRequest.MaxAmountSat, + maxAmountSat, createAppRequest.BudgetRenewal, expiresAt, createAppRequest.Scopes, @@ -118,21 +135,7 @@ func (api *api) CreateApp(createAppRequest *CreateAppRequest) (*CreateAppRespons responseBody.RelayUrls = relayUrls responseBody.Lud16 = lightningAddress - if createAppRequest.ReturnTo != "" { - returnToUrl, err := url.Parse(createAppRequest.ReturnTo) - if err == nil { - query := returnToUrl.Query() - for _, relayUrl := range relayUrls { - query.Add("relay", relayUrl) - } - query.Add("pubkey", *app.WalletPubkey) - if lightningAddress != "" && !app.Isolated { - query.Add("lud16", lightningAddress) - } - returnToUrl.RawQuery = query.Encode() - responseBody.ReturnTo = returnToUrl.String() - } - } + responseBody.ReturnTo = buildReturnToUrl(createAppRequest.ReturnTo, relayUrls, *app.WalletPubkey, lightningAddress, app.Isolated) var lud16 string if lightningAddress != "" && !app.Isolated { @@ -143,7 +146,31 @@ func (api *api) CreateApp(createAppRequest *CreateAppRequest) (*CreateAppRespons return responseBody, nil } +// buildReturnToUrl adds the connection query parameters to the return_to +// URL the user will be redirected to. Only http and https URLs are accepted. +func buildReturnToUrl(returnTo string, relayUrls []string, walletPubkey string, lightningAddress string, isolated bool) string { + if returnTo == "" { + return "" + } + returnToUrl, err := url.Parse(returnTo) + if err != nil || (returnToUrl.Scheme != "http" && returnToUrl.Scheme != "https") { + return "" + } + query := returnToUrl.Query() + for _, relayUrl := range relayUrls { + query.Add("relay", relayUrl) + } + query.Add("pubkey", walletPubkey) + if lightningAddress != "" && !isolated { + query.Add("lud16", lightningAddress) + } + returnToUrl.RawQuery = query.Encode() + return returnToUrl.String() +} + func (api *api) UpdateApp(userApp *db.App, updateAppRequest *UpdateAppRequest) error { + resolvedMaxAmountSat := ResolveToSat(updateAppRequest.MaxAmountSat, updateAppRequest.MaxAmountMsat, updateAppRequest.MaxAmount, nil) + err := api.db.Transaction(func(tx *gorm.DB) error { // Initialize name with current app name, update if provided name := userApp.Name @@ -177,7 +204,7 @@ func (api *api) UpdateApp(userApp *db.App, updateAppRequest *UpdateAppRequest) e }).Error("Failed to deserialize app metadata") return err } - if existingMetadata["app_store_app_id"] == constants.SUBWALLET_APPSTORE_APP_ID { + if existingMetadata[constants.METADATA_APPSTORE_APP_ID_KEY] == constants.SUBWALLET_APPSTORE_APP_ID { return errors.New("Cannot update sub-wallet to be non-isolated") } } @@ -206,11 +233,11 @@ func (api *api) UpdateApp(userApp *db.App, updateAppRequest *UpdateAppRequest) e } // Handle permissions updates only if any permission-related field is provided - if updateAppRequest.Scopes != nil || updateAppRequest.MaxAmountSat != nil || + if updateAppRequest.Scopes != nil || resolvedMaxAmountSat != nil || updateAppRequest.BudgetRenewal != nil || updateAppRequest.ExpiresAt != nil || updateAppRequest.UpdateExpiresAt { // Get current values or use provided ones - var maxAmount uint64 + var maxAmountSat uint64 var budgetRenewal string var expiresAt *time.Time @@ -225,7 +252,7 @@ func (api *api) UpdateApp(userApp *db.App, updateAppRequest *UpdateAppRequest) e // Find pay_invoice permission for budget-related fields for _, perm := range existingPermissions { if perm.Scope == constants.PAY_INVOICE_SCOPE { - maxAmount = uint64(perm.MaxAmountSat) + maxAmountSat = uint64(perm.MaxAmountSat) budgetRenewal = perm.BudgetRenewal expiresAt = perm.ExpiresAt break @@ -234,8 +261,8 @@ func (api *api) UpdateApp(userApp *db.App, updateAppRequest *UpdateAppRequest) e } // Override with provided values - if updateAppRequest.MaxAmountSat != nil { - maxAmount = *updateAppRequest.MaxAmountSat + if resolvedMaxAmountSat != nil { + maxAmountSat = *resolvedMaxAmountSat } if updateAppRequest.BudgetRenewal != nil { budgetRenewal = *updateAppRequest.BudgetRenewal @@ -254,7 +281,7 @@ func (api *api) UpdateApp(userApp *db.App, updateAppRequest *UpdateAppRequest) e // Update existing permissions with new budget and expiry err := tx.Model(&db.AppPermission{}).Where("app_id", userApp.ID).Updates(map[string]interface{}{ "ExpiresAt": expiresAt, - "MaxAmountSat": maxAmount, + "MaxAmountSat": maxAmountSat, "BudgetRenewal": budgetRenewal, }).Error if err != nil { @@ -284,7 +311,7 @@ func (api *api) UpdateApp(userApp *db.App, updateAppRequest *UpdateAppRequest) e App: *userApp, Scope: scope, ExpiresAt: expiresAt, - MaxAmountSat: int(maxAmount), + MaxAmountSat: int(maxAmountSat), BudgetRenewal: budgetRenewal, } if err := tx.Create(&perm).Error; err != nil { @@ -406,12 +433,17 @@ func (api *api) DeleteLightningAddress(ctx context.Context, appId uint) error { return nil } -func (api *api) GetApp(dbApp *db.App) *App { +func (api *api) GetApp(dbApp *db.App) (*App, error) { paySpecificPermission := db.AppPermission{} appPermissions := []db.AppPermission{} var expiresAt *time.Time - api.db.Where("app_id = ?", dbApp.ID).Find(&appPermissions) + if err := api.db.Where("app_id = ?", dbApp.ID).Find(&appPermissions).Error; err != nil { + logger.Logger.WithError(err).WithFields(logrus.Fields{ + "app_id": dbApp.ID, + }).Error("Failed to list app permissions") + return nil, err + } requestMethods := []string{} for _, appPerm := range appPermissions { @@ -424,9 +456,14 @@ func (api *api) GetApp(dbApp *db.App) *App { } // renewsIn := "" - budgetUsage := uint64(0) - maxAmount := uint64(paySpecificPermission.MaxAmountSat) - budgetUsage = queries.GetBudgetUsageSat(api.db, &paySpecificPermission) + maxAmountSat := uint64(paySpecificPermission.MaxAmountSat) + budgetUsageMsat, err := queries.GetBudgetUsageMsat(api.db, &paySpecificPermission) + if err != nil { + logger.Logger.WithError(err).WithFields(logrus.Fields{ + "app_id": dbApp.ID, + }).Error("Failed to get budget usage for app") + return nil, err + } var metadata Metadata if dbApp.Metadata != nil { @@ -446,29 +483,43 @@ func (api *api) GetApp(dbApp *db.App) *App { } response := App{ - ID: dbApp.ID, - Name: dbApp.Name, - Description: dbApp.Description, - CreatedAt: dbApp.CreatedAt, - UpdatedAt: dbApp.UpdatedAt, - AppPubkey: dbApp.AppPubkey, - ExpiresAt: expiresAt, - MaxAmountSat: maxAmount, - Scopes: requestMethods, - BudgetUsage: budgetUsage, - BudgetRenewal: paySpecificPermission.BudgetRenewal, - Isolated: dbApp.Isolated, - Metadata: metadata, - WalletPubkey: walletPubkey, - UniqueWalletPubkey: uniqueWalletPubkey, - LastUsedAt: dbApp.LastUsedAt, + ID: dbApp.ID, + Name: dbApp.Name, + Description: dbApp.Description, + CreatedAt: dbApp.CreatedAt, + UpdatedAt: dbApp.UpdatedAt, + AppPubkey: dbApp.AppPubkey, + ExpiresAt: expiresAt, + MaxAmount: maxAmountSat, + MaxAmountSat: maxAmountSat, + MaxAmountMsat: maxAmountSat * 1000, + Scopes: requestMethods, + BudgetUsage: budgetUsageMsat / 1000, + BudgetUsageSat: budgetUsageMsat / 1000, + BudgetUsageMsat: budgetUsageMsat, + BudgetRenewal: paySpecificPermission.BudgetRenewal, + Isolated: dbApp.Isolated, + Metadata: metadata, + WalletPubkey: walletPubkey, + UniqueWalletPubkey: uniqueWalletPubkey, + LastUsedAt: dbApp.LastUsedAt, + LastSettledTransactionAt: dbApp.LastSettledTransactionAt, } if dbApp.Isolated { - response.Balance = queries.GetIsolatedBalance(api.db, dbApp.ID) + balanceMsat, err := queries.GetIsolatedBalanceMsat(api.db, dbApp.ID) + if err != nil { + logger.Logger.WithError(err).WithFields(logrus.Fields{ + "app_id": dbApp.ID, + }).Error("Failed to get isolated app balance") + return nil, err + } + response.Balance = balanceMsat + response.BalanceSat = balanceMsat / 1000 + response.BalanceMsat = balanceMsat } - return &response + return &response, nil } func (api *api) ListApps(limit uint64, offset uint64, filters ListAppsFilters, orderBy string) (*ListAppsResponse, error) { @@ -487,7 +538,7 @@ func (api *api) ListApps(limit uint64, offset uint64, filters ListAppsFilters, o } if filters.AppStoreAppId != "" { - query = query.Where(datatypes.JSONQuery("metadata").Equals(filters.AppStoreAppId, "app_store_app_id")) + query = query.Where(datatypes.JSONQuery("metadata").Equals(filters.AppStoreAppId, constants.METADATA_APPSTORE_APP_ID_KEY)) } if filters.Unused { @@ -495,24 +546,20 @@ func (api *api) ListApps(limit uint64, offset uint64, filters ListAppsFilters, o query = query.Where("last_used_at IS NULL OR last_used_at < ?", time.Now().Add(-60*24*time.Hour)) } - if filters.SubWallets != nil && !*filters.SubWallets { - // exclude subwallets :scream: - if api.db.Dialector.Name() == "sqlite" { - query = query.Where("metadata is NULL OR JSON_EXTRACT(metadata, '$.app_store_app_id') IS NULL OR JSON_EXTRACT(metadata, '$.app_store_app_id') != ?", constants.SUBWALLET_APPSTORE_APP_ID) + if filters.SubWallets != nil { + if *filters.SubWallets { + query = query.Where(datatypes.JSONQuery("metadata").Equals(constants.SUBWALLET_APPSTORE_APP_ID, constants.METADATA_APPSTORE_APP_ID_KEY)) } else { - query = query.Where("metadata IS NULL OR metadata->>'app_store_app_id' IS NULL OR metadata->>'app_store_app_id' != ?", constants.SUBWALLET_APPSTORE_APP_ID) + // exclude subwallets :scream: + if api.db.Dialector.Name() == "sqlite" { + query = query.Where(fmt.Sprintf("metadata is NULL OR JSON_EXTRACT(metadata, '$.%s') IS NULL OR JSON_EXTRACT(metadata, '$.%s') != ?", constants.METADATA_APPSTORE_APP_ID_KEY, constants.METADATA_APPSTORE_APP_ID_KEY), constants.SUBWALLET_APPSTORE_APP_ID) + } else { + query = query.Where(fmt.Sprintf("metadata IS NULL OR metadata->>'%s' IS NULL OR metadata->>'%s' != ?", constants.METADATA_APPSTORE_APP_ID_KEY, constants.METADATA_APPSTORE_APP_ID_KEY), constants.SUBWALLET_APPSTORE_APP_ID) + } } } - if orderBy == "" { - orderBy = "last_used_at" - } - if orderBy == "last_used_at" { - // when ordering by last used at, apps with last_used_at is NULL should be ordered last - orderBy = "last_used_at IS NULL, " + orderBy - } - - query = query.Order(orderBy + " DESC") + query = query.Order(resolveAppOrderBy(orderBy)) if limit == 0 { limit = 100 @@ -523,6 +570,20 @@ func (api *api) ListApps(limit uint64, offset uint64, filters ListAppsFilters, o logger.Logger.WithError(result.Error).Error("Failed to count DB apps") return nil, result.Error } + + var totalBalance *int64 + var totalBalanceSat *int64 + if filters.SubWallets != nil && *filters.SubWallets { + totalBalanceMsat, err := queries.GetTotalSubwalletBalanceMsat(api.db) + if err != nil { + logger.Logger.WithError(err).Error("Failed to calculate total subwallet balance") + return nil, err + } + totalBalance = &totalBalanceMsat + totalBalanceSatVal := totalBalanceMsat / 1000 + totalBalanceSat = &totalBalanceSatVal + } + query = query.Offset(int(offset)).Limit(int(limit)) err := query.Find(&dbApps).Error @@ -558,20 +619,30 @@ func (api *api) ListApps(limit uint64, offset uint64, filters ListAppsFilters, o uniqueWalletPubkey = true } apiApp := App{ - ID: dbApp.ID, - Name: dbApp.Name, - Description: dbApp.Description, - CreatedAt: dbApp.CreatedAt, - UpdatedAt: dbApp.UpdatedAt, - AppPubkey: dbApp.AppPubkey, - Isolated: dbApp.Isolated, - WalletPubkey: walletPubkey, - UniqueWalletPubkey: uniqueWalletPubkey, - LastUsedAt: dbApp.LastUsedAt, + ID: dbApp.ID, + Name: dbApp.Name, + Description: dbApp.Description, + CreatedAt: dbApp.CreatedAt, + UpdatedAt: dbApp.UpdatedAt, + AppPubkey: dbApp.AppPubkey, + Isolated: dbApp.Isolated, + WalletPubkey: walletPubkey, + UniqueWalletPubkey: uniqueWalletPubkey, + LastUsedAt: dbApp.LastUsedAt, + LastSettledTransactionAt: dbApp.LastSettledTransactionAt, } if dbApp.Isolated { - apiApp.Balance = queries.GetIsolatedBalance(api.db, dbApp.ID) + balanceMsat, err := queries.GetIsolatedBalanceMsat(api.db, dbApp.ID) + if err != nil { + logger.Logger.WithError(err).WithFields(logrus.Fields{ + "app_id": dbApp.ID, + }).Error("Failed to get isolated app balance") + return nil, err + } + apiApp.Balance = balanceMsat + apiApp.BalanceSat = balanceMsat / 1000 + apiApp.BalanceMsat = balanceMsat } for _, appPermission := range permissionsMap[dbApp.ID] { @@ -579,8 +650,19 @@ func (api *api) ListApps(limit uint64, offset uint64, filters ListAppsFilters, o apiApp.ExpiresAt = appPermission.ExpiresAt if appPermission.Scope == constants.PAY_INVOICE_SCOPE { apiApp.BudgetRenewal = appPermission.BudgetRenewal + apiApp.MaxAmount = uint64(appPermission.MaxAmountSat) apiApp.MaxAmountSat = uint64(appPermission.MaxAmountSat) - apiApp.BudgetUsage = queries.GetBudgetUsageSat(api.db, &appPermission) + apiApp.MaxAmountMsat = uint64(appPermission.MaxAmountSat) * 1000 + budgetUsageMsat, err := queries.GetBudgetUsageMsat(api.db, &appPermission) + if err != nil { + logger.Logger.WithError(err).WithFields(logrus.Fields{ + "app_id": dbApp.ID, + }).Error("Failed to get budget usage for app") + return nil, err + } + apiApp.BudgetUsage = budgetUsageMsat / 1000 + apiApp.BudgetUsageSat = budgetUsageMsat / 1000 + apiApp.BudgetUsageMsat = budgetUsageMsat } } @@ -598,16 +680,31 @@ func (api *api) ListApps(limit uint64, offset uint64, filters ListAppsFilters, o apiApps = append(apiApps, apiApp) } return &ListAppsResponse{ - Apps: apiApps, - TotalCount: uint64(totalCount), + Apps: apiApps, + TotalCount: uint64(totalCount), + TotalBalance: totalBalance, + TotalBalanceSat: totalBalanceSat, + TotalBalanceMsat: totalBalance, }, nil } -func (api *api) ListChannels(ctx context.Context) ([]Channel, error) { - if api.svc.GetLNClient() == nil { - return nil, errors.New("LNClient not started") +func resolveAppOrderBy(orderBy string) string { + switch orderBy { + case "created_at": + return "created_at DESC" + case "last_settled_transaction": + return "last_settled_transaction_at IS NULL, last_settled_transaction_at DESC" + default: + return "last_used_at IS NULL, last_used_at DESC" } - channels, err := api.svc.GetLNClient().ListChannels(ctx) +} + +func (api *api) ListChannels(ctx context.Context) ([]Channel, error) { + lnClient := api.svc.GetLNClient() + if lnClient == nil { + return nil, ErrLNClientNotStarted + } + channels, err := lnClient.ListChannels(ctx) if err != nil { return nil, err } @@ -622,9 +719,15 @@ func (api *api) ListChannels(ctx context.Context) ([]Channel, error) { } apiChannels = append(apiChannels, Channel{ - LocalBalance: channel.LocalBalance, - LocalSpendableBalance: channel.LocalSpendableBalance, - RemoteBalance: channel.RemoteBalance, + LocalBalance: channel.LocalBalanceMsat, + LocalBalanceSat: channel.LocalBalanceMsat / 1000, + LocalBalanceMsat: channel.LocalBalanceMsat, + LocalSpendableBalance: channel.LocalSpendableBalanceMsat, + LocalSpendableBalanceSat: channel.LocalSpendableBalanceMsat / 1000, + LocalSpendableBalanceMsat: channel.LocalSpendableBalanceMsat, + RemoteBalance: channel.RemoteBalanceMsat, + RemoteBalanceSat: channel.RemoteBalanceMsat / 1000, + RemoteBalanceMsat: channel.RemoteBalanceMsat, Id: channel.Id, RemotePubkey: channel.RemotePubkey, FundingTxId: channel.FundingTxId, @@ -636,11 +739,13 @@ func (api *api) ListChannels(ctx context.Context) ([]Channel, error) { ConfirmationsRequired: channel.ConfirmationsRequired, ForwardingFeeBaseMsat: channel.ForwardingFeeBaseMsat, ForwardingFeeProportionalMillionths: channel.ForwardingFeeProportionalMillionths, - UnspendablePunishmentReserve: channel.UnspendablePunishmentReserve, - CounterpartyUnspendablePunishmentReserve: channel.CounterpartyUnspendablePunishmentReserve, - Error: channel.Error, - IsOutbound: channel.IsOutbound, - Status: status, + UnspendablePunishmentReserve: channel.UnspendablePunishmentReserveSat, + UnspendablePunishmentReserveSat: channel.UnspendablePunishmentReserveSat, + CounterpartyUnspendablePunishmentReserve: channel.CounterpartyUnspendablePunishmentReserveSat, + CounterpartyUnspendablePunishmentReserveSat: channel.CounterpartyUnspendablePunishmentReserveSat, + Error: channel.Error, + IsOutbound: channel.IsOutbound, + Status: status, }) } @@ -668,15 +773,20 @@ func (api *api) GetChannelPeerSuggestions(ctx context.Context) ([]alby.ChannelPe return api.albySvc.GetChannelPeerSuggestions(ctx) } +func (api *api) GetStories(ctx context.Context) ([]alby.Story, error) { + return api.albyOAuthSvc.GetStories(ctx) +} + func (api *api) GetLSPChannelOffer(ctx context.Context) (*alby.LSPChannelOffer, error) { return api.albyOAuthSvc.GetLSPChannelOffer(ctx) } func (api *api) ResetRouter(key string) error { - if api.svc.GetLNClient() == nil { - return errors.New("LNClient not started") + lnClient := api.svc.GetLNClient() + if lnClient == nil { + return ErrLNClientNotStarted } - err := api.svc.GetLNClient().ResetRouter(key) + err := lnClient.ResetRouter(key) if err != nil { return err } @@ -688,7 +798,7 @@ func (api *api) ResetRouter(key string) error { func (api *api) ChangeUnlockPassword(changeUnlockPasswordRequest *ChangeUnlockPasswordRequest) error { if api.svc.GetLNClient() == nil { - return errors.New("LNClient not started") + return ErrLNClientNotStarted } autoUnlockPassword, err := api.cfg.Get("AutoUnlockPassword", "") @@ -713,7 +823,7 @@ func (api *api) ChangeUnlockPassword(changeUnlockPasswordRequest *ChangeUnlockPa func (api *api) SetAutoUnlockPassword(unlockPassword string) error { if api.svc.GetLNClient() == nil { - return errors.New("LNClient not started") + return ErrLNClientNotStarted } err := api.cfg.SetAutoUnlockPassword(unlockPassword) @@ -735,7 +845,7 @@ func (api *api) Stop() error { logger.Logger.Info("Running Stop command") if api.svc.GetLNClient() == nil { - return errors.New("LNClient not started") + return ErrLNClientNotStarted } // stop the lnclient, nostr relay etc. @@ -745,11 +855,20 @@ func (api *api) Stop() error { return nil } -func (api *api) GetNodeConnectionInfo(ctx context.Context) (*lnclient.NodeConnectionInfo, error) { - if api.svc.GetLNClient() == nil { - return nil, errors.New("LNClient not started") +func (api *api) GetNodeConnectionInfo(ctx context.Context) (*NodeConnectionInfo, error) { + lnClient := api.svc.GetLNClient() + if lnClient == nil { + return nil, ErrLNClientNotStarted } - return api.svc.GetLNClient().GetNodeConnectionInfo(ctx) + info, err := lnClient.GetNodeConnectionInfo(ctx) + if err != nil { + return nil, err + } + return &NodeConnectionInfo{ + Pubkey: info.Pubkey, + Address: info.Address, + Port: info.Port, + }, nil } func (api *api) RefundSwap(refundSwapRequest *RefundSwapRequest) error { @@ -760,28 +879,38 @@ func (api *api) RefundSwap(refundSwapRequest *RefundSwapRequest) error { } func (api *api) GetAutoSwapConfig() (*GetAutoSwapConfigResponse, error) { + if api.svc.GetSwapsService() == nil { + return nil, errors.New("SwapsService not started") + } + swapOutBalanceThresholdStr, _ := api.cfg.Get(config.AutoSwapBalanceThresholdKey, "") swapOutAmountStr, _ := api.cfg.Get(config.AutoSwapAmountKey, "") swapOutDestination, _ := api.cfg.Get(config.AutoSwapDestinationKey, "") + if xpub := api.svc.GetSwapsService().GetDecryptedAutoSwapXpub(); xpub != "" { + swapOutDestination = xpub + } + swapOutEnabled := swapOutBalanceThresholdStr != "" && swapOutAmountStr != "" - var swapOutBalanceThreshold, swapOutAmount uint64 + var swapOutBalanceThresholdSat, swapOutAmountSat uint64 if swapOutEnabled { var err error - if swapOutBalanceThreshold, err = strconv.ParseUint(swapOutBalanceThresholdStr, 10, 64); err != nil { + if swapOutBalanceThresholdSat, err = strconv.ParseUint(swapOutBalanceThresholdStr, 10, 64); err != nil { return nil, fmt.Errorf("invalid autoswap out balance threshold: %w", err) } - if swapOutAmount, err = strconv.ParseUint(swapOutAmountStr, 10, 64); err != nil { + if swapOutAmountSat, err = strconv.ParseUint(swapOutAmountStr, 10, 64); err != nil { return nil, fmt.Errorf("invalid autoswap out amount: %w", err) } } return &GetAutoSwapConfigResponse{ - Type: constants.SWAP_TYPE_OUT, - Enabled: swapOutEnabled, - BalanceThreshold: swapOutBalanceThreshold, - SwapAmount: swapOutAmount, - Destination: swapOutDestination, + Type: constants.SWAP_TYPE_OUT, + Enabled: swapOutEnabled, + BalanceThreshold: swapOutBalanceThresholdSat, + BalanceThresholdSat: swapOutBalanceThresholdSat, + SwapAmount: swapOutAmountSat, + SwapAmountSat: swapOutAmountSat, + Destination: swapOutDestination, }, nil } @@ -823,8 +952,10 @@ func toApiSwap(swap *swaps.Swap) *Swap { Type: swap.Type, State: swap.State, Invoice: swap.Invoice, - SendAmount: swap.SendAmount, - ReceiveAmount: swap.ReceiveAmount, + SendAmount: swap.SendAmountSat, + SendAmountSat: swap.SendAmountSat, + ReceiveAmount: swap.ReceiveAmountSat, + ReceiveAmountSat: swap.ReceiveAmountSat, PaymentHash: swap.PaymentHash, DestinationAddress: swap.DestinationAddress, RefundAddress: swap.RefundAddress, @@ -850,11 +981,14 @@ func (api *api) GetSwapInInfo() (*SwapInfoResponse, error) { } return &SwapInfoResponse{ - AlbyServiceFee: swapInInfo.AlbyServiceFee, - BoltzServiceFee: swapInInfo.BoltzServiceFee, - BoltzNetworkFee: swapInInfo.BoltzNetworkFee, - MinAmount: swapInInfo.MinAmount, - MaxAmount: swapInInfo.MaxAmount, + AlbyServiceFee: swapInInfo.AlbyServiceFee, + BoltzServiceFee: swapInInfo.BoltzServiceFee, + BoltzNetworkFee: swapInInfo.BoltzNetworkFeeSat, + BoltzNetworkFeeSat: swapInInfo.BoltzNetworkFeeSat, + MinAmount: swapInInfo.MinAmountSat, + MinAmountSat: swapInInfo.MinAmountSat, + MaxAmount: swapInInfo.MaxAmountSat, + MaxAmountSat: swapInInfo.MaxAmountSat, }, nil } @@ -869,35 +1003,42 @@ func (api *api) GetSwapOutInfo() (*SwapInfoResponse, error) { } return &SwapInfoResponse{ - AlbyServiceFee: swapOutInfo.AlbyServiceFee, - BoltzServiceFee: swapOutInfo.BoltzServiceFee, - BoltzNetworkFee: swapOutInfo.BoltzNetworkFee, - MinAmount: swapOutInfo.MinAmount, - MaxAmount: swapOutInfo.MaxAmount, + AlbyServiceFee: swapOutInfo.AlbyServiceFee, + BoltzServiceFee: swapOutInfo.BoltzServiceFee, + BoltzNetworkFee: swapOutInfo.BoltzNetworkFeeSat, + BoltzNetworkFeeSat: swapOutInfo.BoltzNetworkFeeSat, + MinAmount: swapOutInfo.MinAmountSat, + MinAmountSat: swapOutInfo.MinAmountSat, + MaxAmount: swapOutInfo.MaxAmountSat, + MaxAmountSat: swapOutInfo.MaxAmountSat, }, nil } func (api *api) InitiateSwapOut(ctx context.Context, initiateSwapOutRequest *InitiateSwapRequest) (*swaps.SwapResponse, error) { lnClient := api.svc.GetLNClient() if lnClient == nil { - return nil, errors.New("LNClient not started") + return nil, ErrLNClientNotStarted } if api.svc.GetSwapsService() == nil { return nil, errors.New("SwapsService not started") } - amount := initiateSwapOutRequest.SwapAmount + amountSat := uint64(0) + resolvedAmountSat := ResolveToSat(initiateSwapOutRequest.SwapAmountSat, nil, initiateSwapOutRequest.SwapAmount, nil) + if resolvedAmountSat != nil { + amountSat = *resolvedAmountSat + } destination := initiateSwapOutRequest.Destination - if amount == 0 { + if amountSat == 0 { return nil, errors.New("invalid swap amount") } - swapOutResponse, err := api.svc.GetSwapsService().SwapOut(amount, destination, false, false) + swapOutResponse, err := api.svc.GetSwapsService().SwapOut(amountSat, destination, false, false) if err != nil { logger.Logger.WithFields(logrus.Fields{ - "amount": amount, + "amount_sat": amountSat, "destination": destination, }).WithError(err).Error("Failed to initiate swap out") return nil, err @@ -909,23 +1050,27 @@ func (api *api) InitiateSwapOut(ctx context.Context, initiateSwapOutRequest *Ini func (api *api) InitiateSwapIn(ctx context.Context, initiateSwapInRequest *InitiateSwapRequest) (*swaps.SwapResponse, error) { lnClient := api.svc.GetLNClient() if lnClient == nil { - return nil, errors.New("LNClient not started") + return nil, ErrLNClientNotStarted } if api.svc.GetSwapsService() == nil { return nil, errors.New("SwapsService not started") } - amount := initiateSwapInRequest.SwapAmount + amountSat := uint64(0) + resolvedAmountSat := ResolveToSat(initiateSwapInRequest.SwapAmountSat, nil, initiateSwapInRequest.SwapAmount, nil) + if resolvedAmountSat != nil { + amountSat = *resolvedAmountSat + } - if amount == 0 { + if amountSat == 0 { return nil, errors.New("invalid swap amount") } - swapInResponse, err := api.svc.GetSwapsService().SwapIn(amount, false) + swapInResponse, err := api.svc.GetSwapsService().SwapIn(amountSat, false) if err != nil { logger.Logger.WithFields(logrus.Fields{ - "amount": amount, + "amount_sat": amountSat, }).WithError(err).Error("Failed to initiate swap in") return nil, err } @@ -934,28 +1079,61 @@ func (api *api) InitiateSwapIn(ctx context.Context, initiateSwapInRequest *Initi } func (api *api) EnableAutoSwapOut(ctx context.Context, enableAutoSwapsRequest *EnableAutoSwapRequest) error { - err := api.cfg.SetUpdate(config.AutoSwapBalanceThresholdKey, strconv.FormatUint(enableAutoSwapsRequest.BalanceThreshold, 10), "") + if api.svc.GetSwapsService() == nil { + return errors.New("SwapsService not started") + } + + encryptionKey := "" + if enableAutoSwapsRequest.Destination != "" { + switch enableAutoSwapsRequest.DestinationType { + case "address": + if err := api.svc.GetSwapsService().ValidateAddress(enableAutoSwapsRequest.Destination); err != nil { + return err + } + case "xpub": + if !api.cfg.CheckUnlockPassword(enableAutoSwapsRequest.UnlockPassword) { + return errors.New("invalid unlock password") + } + if err := api.svc.GetSwapsService().ValidateXpub(enableAutoSwapsRequest.Destination); err != nil { + return err + } + encryptionKey = enableAutoSwapsRequest.UnlockPassword + default: + return errors.New("destination type must be address or xpub") + } + } + + balanceThresholdSat := uint64(0) + resolvedBalanceThresholdSat := ResolveToSat(enableAutoSwapsRequest.BalanceThresholdSat, nil, enableAutoSwapsRequest.BalanceThreshold, nil) + if resolvedBalanceThresholdSat != nil { + balanceThresholdSat = *resolvedBalanceThresholdSat + } + + err := api.cfg.SetUpdate(config.AutoSwapBalanceThresholdKey, strconv.FormatUint(balanceThresholdSat, 10), "") if err != nil { logger.Logger.WithError(err).Error("Failed to save autoswap balance threshold to config") return err } - err = api.cfg.SetUpdate(config.AutoSwapAmountKey, strconv.FormatUint(enableAutoSwapsRequest.SwapAmount, 10), "") + swapAmountSat := uint64(0) + resolvedSwapAmountSat := ResolveToSat(enableAutoSwapsRequest.SwapAmountSat, nil, enableAutoSwapsRequest.SwapAmount, nil) + if resolvedSwapAmountSat != nil { + swapAmountSat = *resolvedSwapAmountSat + } + + err = api.cfg.SetUpdate(config.AutoSwapAmountKey, strconv.FormatUint(swapAmountSat, 10), "") if err != nil { logger.Logger.WithError(err).Error("Failed to save autoswap amount to config") return err } - err = api.cfg.SetUpdate(config.AutoSwapDestinationKey, enableAutoSwapsRequest.Destination, "") + err = api.cfg.SetUpdate(config.AutoSwapDestinationKey, enableAutoSwapsRequest.Destination, encryptionKey) if err != nil { logger.Logger.WithError(err).Error("Failed to save autoswap destination to config") return err } - if api.svc.GetSwapsService() == nil { - return errors.New("SwapsService not started") - } - return api.svc.GetSwapsService().EnableAutoSwapOut() + return api.svc.GetSwapsService().EnableAutoSwapOut(enableAutoSwapsRequest.UnlockPassword) } func (api *api) DisableAutoSwap() error { @@ -978,75 +1156,134 @@ func (api *api) GetSwapMnemonic() string { return api.keys.GetSwapMnemonic() } -func (api *api) GetNodeStatus(ctx context.Context) (*lnclient.NodeStatus, error) { - if api.svc.GetLNClient() == nil { - return nil, errors.New("LNClient not started") +func (api *api) GetNodeStatus(ctx context.Context) (*NodeStatus, error) { + lnClient := api.svc.GetLNClient() + if lnClient == nil { + return nil, ErrLNClientNotStarted } - return api.svc.GetLNClient().GetNodeStatus(ctx) + nodeStatus, err := lnClient.GetNodeStatus(ctx) + if err != nil { + return nil, err + } + if nodeStatus == nil { + return nil, nil + } + return toApiNodeStatus(nodeStatus), nil } -func (api *api) ListPeers(ctx context.Context) ([]lnclient.PeerDetails, error) { - if api.svc.GetLNClient() == nil { - return nil, errors.New("LNClient not started") +func toApiNodeStatus(nodeStatus *lnclient.NodeStatus) *NodeStatus { + return &NodeStatus{ + IsReady: nodeStatus.IsReady, + InternalNodeStatus: nodeStatus.InternalNodeStatus, } - return api.svc.GetLNClient().ListPeers(ctx) +} + +func (api *api) ListPeers(ctx context.Context) ([]PeerDetails, error) { + lnClient := api.svc.GetLNClient() + if lnClient == nil { + return nil, ErrLNClientNotStarted + } + peers, err := lnClient.ListPeers(ctx) + if err != nil { + return nil, err + } + apiPeers := make([]PeerDetails, 0, len(peers)) + for _, peer := range peers { + apiPeers = append(apiPeers, PeerDetails{ + NodeId: peer.NodeId, + Address: peer.Address, + IsPersisted: peer.IsPersisted, + IsConnected: peer.IsConnected, + }) + } + return apiPeers, nil } func (api *api) ConnectPeer(ctx context.Context, connectPeerRequest *ConnectPeerRequest) error { - if api.svc.GetLNClient() == nil { - return errors.New("LNClient not started") + lnClient := api.svc.GetLNClient() + if lnClient == nil { + return ErrLNClientNotStarted } - return api.svc.GetLNClient().ConnectPeer(ctx, connectPeerRequest) + return lnClient.ConnectPeer(ctx, &lnclient.ConnectPeerRequest{ + Pubkey: connectPeerRequest.Pubkey, + Address: connectPeerRequest.Address, + Port: connectPeerRequest.Port, + }) } func (api *api) OpenChannel(ctx context.Context, openChannelRequest *OpenChannelRequest) (*OpenChannelResponse, error) { - if api.svc.GetLNClient() == nil { - return nil, errors.New("LNClient not started") + lnClient := api.svc.GetLNClient() + if lnClient == nil { + return nil, ErrLNClientNotStarted } - return api.svc.GetLNClient().OpenChannel(ctx, openChannelRequest) + resp, err := lnClient.OpenChannel(ctx, &lnclient.OpenChannelRequest{ + Pubkey: openChannelRequest.Pubkey, + AmountSats: openChannelRequest.AmountSats, + Public: openChannelRequest.Public, + }) + if err != nil { + return nil, err + } + return &OpenChannelResponse{ + FundingTxId: resp.FundingTxId, + }, nil } func (api *api) DisconnectPeer(ctx context.Context, peerId string) error { - if api.svc.GetLNClient() == nil { - return errors.New("LNClient not started") + lnClient := api.svc.GetLNClient() + if lnClient == nil { + return ErrLNClientNotStarted } logger.Logger.WithFields(logrus.Fields{ "peer_id": peerId, }).Info("Disconnecting peer") - return api.svc.GetLNClient().DisconnectPeer(ctx, peerId) + return lnClient.DisconnectPeer(ctx, peerId) } func (api *api) CloseChannel(ctx context.Context, peerId, channelId string, force bool) (*CloseChannelResponse, error) { - if api.svc.GetLNClient() == nil { - return nil, errors.New("LNClient not started") + lnClient := api.svc.GetLNClient() + if lnClient == nil { + return nil, ErrLNClientNotStarted } logger.Logger.WithFields(logrus.Fields{ "peer_id": peerId, "channel_id": channelId, "force": force, }).Info("Closing channel") - return api.svc.GetLNClient().CloseChannel(ctx, &lnclient.CloseChannelRequest{ + err := lnClient.CloseChannel(ctx, &lnclient.CloseChannelRequest{ NodeId: peerId, ChannelId: channelId, Force: force, }) + if err != nil { + return nil, err + } + return &CloseChannelResponse{}, nil } func (api *api) UpdateChannel(ctx context.Context, updateChannelRequest *UpdateChannelRequest) error { - if api.svc.GetLNClient() == nil { - return errors.New("LNClient not started") + lnClient := api.svc.GetLNClient() + if lnClient == nil { + return ErrLNClientNotStarted } logger.Logger.WithFields(logrus.Fields{ "request": updateChannelRequest, }).Info("updating channel") - return api.svc.GetLNClient().UpdateChannel(ctx, updateChannelRequest) + return lnClient.UpdateChannel(ctx, &lnclient.UpdateChannelRequest{ + ChannelId: updateChannelRequest.ChannelId, + NodeId: updateChannelRequest.NodeId, + ForwardingFeeBaseMsat: updateChannelRequest.ForwardingFeeBaseMsat, + ForwardingFeeProportionalMillionths: updateChannelRequest.ForwardingFeeProportionalMillionths, + MaxDustHtlcExposureFromFeeRateMultiplier: updateChannelRequest.MaxDustHtlcExposureFromFeeRateMultiplier, + }) } func (api *api) MakeOffer(ctx context.Context, description string) (string, error) { - if api.svc.GetLNClient() == nil { - return "", errors.New("LNClient not started") + lnClient := api.svc.GetLNClient() + if lnClient == nil { + return "", ErrLNClientNotStarted } - offer, err := api.svc.GetLNClient().MakeOffer(ctx, description) + offer, err := lnClient.MakeOffer(ctx, description) if err != nil { return "", err } @@ -1055,10 +1292,11 @@ func (api *api) MakeOffer(ctx context.Context, description string) (string, erro } func (api *api) GetNewOnchainAddress(ctx context.Context) (string, error) { - if api.svc.GetLNClient() == nil { - return "", errors.New("LNClient not started") + lnClient := api.svc.GetLNClient() + if lnClient == nil { + return "", ErrLNClientNotStarted } - address, err := api.svc.GetLNClient().GetNewOnchainAddress(ctx) + address, err := lnClient.GetNewOnchainAddress(ctx) if err != nil { return "", err } @@ -1073,7 +1311,7 @@ func (api *api) GetNewOnchainAddress(ctx context.Context) (string, error) { func (api *api) GetUnusedOnchainAddress(ctx context.Context) (string, error) { if api.svc.GetLNClient() == nil { - return "", errors.New("LNClient not started") + return "", ErrLNClientNotStarted } currentAddress, err := api.cfg.Get(config.OnchainAddressKey, "") @@ -1111,10 +1349,11 @@ func (api *api) GetUnusedOnchainAddress(ctx context.Context) (string, error) { } func (api *api) SignMessage(ctx context.Context, message string) (*SignMessageResponse, error) { - if api.svc.GetLNClient() == nil { - return nil, errors.New("LNClient not started") + lnClient := api.svc.GetLNClient() + if lnClient == nil { + return nil, ErrLNClientNotStarted } - signature, err := api.svc.GetLNClient().SignMessage(ctx, message) + signature, err := lnClient.SignMessage(ctx, message) if err != nil { return nil, err } @@ -1124,11 +1363,12 @@ func (api *api) SignMessage(ctx context.Context, message string) (*SignMessageRe }, nil } -func (api *api) RedeemOnchainFunds(ctx context.Context, toAddress string, amount uint64, feeRate *uint64, sendAll bool) (*RedeemOnchainFundsResponse, error) { - if api.svc.GetLNClient() == nil { - return nil, errors.New("LNClient not started") +func (api *api) RedeemOnchainFunds(ctx context.Context, toAddress string, amountSat uint64, feeRate *uint64, sendAll bool) (*RedeemOnchainFundsResponse, error) { + lnClient := api.svc.GetLNClient() + if lnClient == nil { + return nil, ErrLNClientNotStarted } - txId, err := api.svc.GetLNClient().RedeemOnchainFunds(ctx, toAddress, amount, feeRate, sendAll) + txId, err := lnClient.RedeemOnchainFunds(ctx, toAddress, amountSat, feeRate, sendAll) if err != nil { return nil, err } @@ -1138,14 +1378,78 @@ func (api *api) RedeemOnchainFunds(ctx context.Context, toAddress string, amount } func (api *api) GetBalances(ctx context.Context) (*BalancesResponse, error) { - if api.svc.GetLNClient() == nil { - return nil, errors.New("LNClient not started") + lnClient := api.svc.GetLNClient() + if lnClient == nil { + return nil, ErrLNClientNotStarted } - balances, err := api.svc.GetLNClient().GetBalances(ctx, false) + balances, err := lnClient.GetBalances(ctx, false) if err != nil { return nil, err } - return balances, nil + return toApiBalances(balances), nil +} + +func toApiBalances(balances *lnclient.BalancesResponse) *BalancesResponse { + totalSpendableMsat := balances.Lightning.TotalSpendableMsat + totalReceivableMsat := balances.Lightning.TotalReceivableMsat + nextMaxSpendableMsat := balances.Lightning.NextMaxSpendableMsat + nextMaxReceivableMsat := balances.Lightning.NextMaxReceivableMsat + nextMaxSpendableMPPMsat := balances.Lightning.NextMaxSpendableMPPMsat + nextMaxReceivableMPPMsat := balances.Lightning.NextMaxReceivableMPPMsat + + return &BalancesResponse{ + Onchain: OnchainBalanceResponse{ + Spendable: balances.Onchain.SpendableSat, + SpendableSat: balances.Onchain.SpendableSat, + Total: balances.Onchain.TotalSat, + TotalSat: balances.Onchain.TotalSat, + Reserved: balances.Onchain.ReservedSat, + ReservedSat: balances.Onchain.ReservedSat, + PendingBalancesFromChannelClosures: balances.Onchain.PendingBalancesFromChannelClosuresSat, + PendingBalancesFromChannelClosuresSat: balances.Onchain.PendingBalancesFromChannelClosuresSat, + PendingBalancesDetails: toApiPendingBalanceDetails(balances.Onchain.PendingBalancesDetails), + PendingSweepBalancesDetails: toApiPendingBalanceDetails(balances.Onchain.PendingSweepBalancesDetails), + InternalBalances: balances.Onchain.InternalBalances, + }, + Lightning: LightningBalanceResponse{ + TotalSpendable: totalSpendableMsat, + TotalSpendableSat: totalSpendableMsat / 1000, + TotalSpendableMsat: totalSpendableMsat, + TotalReceivable: totalReceivableMsat, + TotalReceivableSat: totalReceivableMsat / 1000, + TotalReceivableMsat: totalReceivableMsat, + NextMaxSpendable: nextMaxSpendableMsat, + NextMaxSpendableSat: nextMaxSpendableMsat / 1000, + NextMaxSpendableMsat: nextMaxSpendableMsat, + NextMaxReceivable: nextMaxReceivableMsat, + NextMaxReceivableSat: nextMaxReceivableMsat / 1000, + NextMaxReceivableMsat: nextMaxReceivableMsat, + NextMaxSpendableMPP: nextMaxSpendableMPPMsat, + NextMaxSpendableMPPSat: nextMaxSpendableMPPMsat / 1000, + NextMaxSpendableMPPMsat: nextMaxSpendableMPPMsat, + NextMaxReceivableMPP: nextMaxReceivableMPPMsat, + NextMaxReceivableMPPSat: nextMaxReceivableMPPMsat / 1000, + NextMaxReceivableMPPMsat: nextMaxReceivableMPPMsat, + }, + } +} + +func toApiPendingBalanceDetails(details []lnclient.PendingBalanceDetails) []PendingBalanceDetails { + if details == nil { + return nil + } + apiDetails := make([]PendingBalanceDetails, 0, len(details)) + for _, d := range details { + apiDetails = append(apiDetails, PendingBalanceDetails{ + ChannelId: d.ChannelId, + NodeId: d.NodeId, + Amount: d.AmountSat, + AmountSat: d.AmountSat, + FundingTxId: d.FundingTxId, + FundingTxVout: d.FundingTxVout, + }) + } + return apiDetails } // TODO: remove dependency on this endpoint @@ -1182,6 +1486,15 @@ func (api *api) RequestMempoolApi(ctx context.Context, endpoint string) (interfa return nil, errors.New("failed to read response body") } + if res.StatusCode != http.StatusOK { + logger.Logger.WithFields(logrus.Fields{ + "endpoint": endpoint, + "status_code": res.StatusCode, + "body": string(body), + }).Error("Mempool endpoint returned non-success code") + return nil, fmt.Errorf("mempool endpoint returned non-success code: %s", string(body)) + } + var jsonContent interface{} jsonErr := json.Unmarshal(body, &jsonContent) if jsonErr != nil { @@ -1195,10 +1508,29 @@ func (api *api) RequestMempoolApi(ctx context.Context, endpoint string) (interfa func (api *api) GetInfo(ctx context.Context) (*InfoResponse, error) { info := InfoResponse{} + + if api.nodeMigrationFileCreated.Load() { + // the hub is halted and the database is closed after a migration file + // is created, so return a minimal response without reading any config + // or node state; the frontend only needs the flag to keep showing the + // migration success page + info.NodeMigrationFileCreated = true + info.SetupCompleted = true + info.Version = version.Tag + info.Relays = []InfoResponseRelay{} + return &info, nil + } + backendType, _ := api.cfg.Get("LNBackendType", "") ldkVssEnabled, _ := api.cfg.Get("LdkVssEnabled", "") + jitChannelsEnabled, _ := api.cfg.Get("JitChannelsEnabled", "") autoUnlockPassword, _ := api.cfg.Get("AutoUnlockPassword", "") - info.SetupCompleted = api.cfg.SetupCompleted() + setupCompleted, err := api.cfg.SetupCompleted() + if err != nil { + logger.Logger.WithError(err).Error("Failed to check if setup is completed") + return nil, err + } + info.SetupCompleted = setupCompleted info.Currency = api.cfg.GetCurrency() info.BitcoinDisplayFormat = api.cfg.GetBitcoinDisplayFormat() info.StartupState = api.svc.GetStartupState() @@ -1206,14 +1538,21 @@ func (api *api) GetInfo(ctx context.Context) (*InfoResponse, error) { info.StartupError = api.startupError.Error() info.StartupErrorTime = api.startupErrorTime } - info.Running = api.svc.GetLNClient() != nil + lnClient := api.svc.GetLNClient() + info.Running = lnClient != nil + info.NodeMigrationFileCreated = api.nodeMigrationFileCreated.Load() info.BackendType = backendType info.AlbyAuthUrl = api.albyOAuthSvc.GetAuthUrl() info.OAuthRedirect = !api.cfg.GetEnv().IsDefaultClientId() info.Version = version.Tag info.EnableAdvancedSetup = api.cfg.GetEnv().EnableAdvancedSetup + info.HideUpdateBanner = api.cfg.GetEnv().HideUpdateBanner info.LdkVssEnabled = ldkVssEnabled == "true" + info.JitChannelsEnabled = jitChannelsEnabled != "false" info.VssSupported = backendType == config.LDKBackendType && api.cfg.GetEnv().LDKVssUrl != "" + info.LdkVssUrl = api.cfg.GetEnv().LDKVssUrl + info.DatabaseType = api.db.Dialector.Name() + info.SupportsBolt12 = backendType == config.LDKBackendType || backendType == config.CLNBackendType info.AutoUnlockPasswordEnabled = autoUnlockPassword != "" info.AutoUnlockPasswordSupported = api.cfg.GetEnv().IsDefaultClientId() info.Relays = []InfoResponseRelay{} @@ -1234,14 +1573,43 @@ func (api *api) GetInfo(ctx context.Context) (*InfoResponse, error) { } info.AlbyUserIdentifier = albyUserIdentifier - if api.svc.GetLNClient() != nil { - nodeInfo, err := api.svc.GetLNClient().GetInfo(ctx) + if lnClient != nil { + nodeInfo, err := lnClient.GetInfo(ctx) if err != nil { logger.Logger.WithError(err).Error("Failed to get nodeInfo") return nil, err } info.Network = nodeInfo.Network + if backendType == config.LDKBackendType { + // Only LDK supports this right now. Using a local interface here + // so we don't have to bloat the main LNClient interface for everyone else. + type chainSourceProvider interface { + GetChainDataSource() (string, string) + } + type lsps2SourceProvider interface { + GetLiquiditySourceLsps2() string + } + type lsps2MinPaymentSizeProvider interface { + GetLiquiditySourceLsps2MinPaymentSizeMsat() *uint64 + } + type lsps2MaxPaymentSizeProvider interface { + GetLiquiditySourceLsps2MaxPaymentSizeMsat() *uint64 + } + + if ldkService, ok := api.svc.GetLNClient().(chainSourceProvider); ok { + info.ChainDataSourceType, info.ChainDataSourceAddress = ldkService.GetChainDataSource() + } + if ldkService, ok := api.svc.GetLNClient().(lsps2SourceProvider); ok { + info.JitChannelsLiquiditySource = ldkService.GetLiquiditySourceLsps2() + } + if ldkService, ok := api.svc.GetLNClient().(lsps2MinPaymentSizeProvider); ok { + info.JitChannelsMinPaymentSizeMsat = ldkService.GetLiquiditySourceLsps2MinPaymentSizeMsat() + } + if ldkService, ok := api.svc.GetLNClient().(lsps2MaxPaymentSizeProvider); ok { + info.JitChannelsMaxPaymentSizeMsat = ldkService.GetLiquiditySourceLsps2MaxPaymentSizeMsat() + } + } } info.NextBackupReminder, _ = api.cfg.Get("NextBackupReminder", "") @@ -1251,7 +1619,7 @@ func (api *api) GetInfo(ctx context.Context) (*InfoResponse, error) { return &info, nil } -func (api *api) SetCurrency(currency string) error { +func (api *api) setCurrency(currency string) error { if currency == "" { return fmt.Errorf("currency value cannot be empty") } @@ -1265,7 +1633,7 @@ func (api *api) SetCurrency(currency string) error { return nil } -func (api *api) SetBitcoinDisplayFormat(format string) error { +func (api *api) setBitcoinDisplayFormat(format string) error { if format != constants.BITCOIN_DISPLAY_FORMAT_SATS && format != constants.BITCOIN_DISPLAY_FORMAT_BIP177 { return fmt.Errorf("bitcoin display format must be '%s' or '%s'", constants.BITCOIN_DISPLAY_FORMAT_SATS, constants.BITCOIN_DISPLAY_FORMAT_BIP177) } @@ -1279,21 +1647,43 @@ func (api *api) SetBitcoinDisplayFormat(format string) error { return nil } +func (api *api) setJitChannelsEnabled(enabled bool) error { + value := "true" + if !enabled { + value = "false" + } + + err := api.cfg.SetUpdate("JitChannelsEnabled", value, "") + if err != nil { + logger.Logger.WithError(err).Error("Failed to update JIT channels setting") + return err + } + + return nil +} + func (api *api) UpdateSettings(updateSettingsRequest *UpdateSettingsRequest) error { if updateSettingsRequest.Currency != "" { - err := api.SetCurrency(updateSettingsRequest.Currency) + err := api.setCurrency(updateSettingsRequest.Currency) if err != nil { return fmt.Errorf("failed to set currency: %w", err) } } if updateSettingsRequest.BitcoinDisplayFormat != "" { - err := api.SetBitcoinDisplayFormat(updateSettingsRequest.BitcoinDisplayFormat) + err := api.setBitcoinDisplayFormat(updateSettingsRequest.BitcoinDisplayFormat) if err != nil { return fmt.Errorf("failed to set bitcoin display format: %w", err) } } + if updateSettingsRequest.JitChannelsEnabled != nil { + err := api.setJitChannelsEnabled(*updateSettingsRequest.JitChannelsEnabled) + if err != nil { + return fmt.Errorf("failed to set JIT channels setting: %w", err) + } + } + return nil } @@ -1373,6 +1763,19 @@ func (api *api) Setup(ctx context.Context, setupRequest *SetupRequest) error { return errors.New("no unlock password provided") } + // Bark and Cashu both store wallet state on local disk, so they cannot + // run in environments without persistent volumes (e.g. Alby Cloud). Bark + // can recover spendable VTXOs from the mnemonic alone, but in-flight + // payment checkpoints and wallet metadata are local-only, so persistent + // storage is still required. The default OAuth client ID identifies a + // local / self-hosted deployment. + if !api.cfg.GetEnv().IsDefaultClientId() { + switch setupRequest.LNBackendType { + case config.BarkBackendType, config.CashuBackendType: + return fmt.Errorf("%s backend is not supported in this environment (no persistent storage)", setupRequest.LNBackendType) + } + } + err = api.cfg.SaveUnlockPasswordCheck(setupRequest.UnlockPassword) if err != nil { return err @@ -1406,15 +1809,38 @@ func (api *api) Setup(ctx context.Context, setupRequest *SetupRequest) error { return err } } - if setupRequest.LNDCertHex != "" { - err = api.cfg.SetUpdate("LNDCertHex", setupRequest.LNDCertHex, setupRequest.UnlockPassword) + if setupRequest.LNDCertFile != "" { + // The file path is provided by the (unauthenticated) setup request, so + // only persist the content if it parses as a certificate. Storing the + // re-encoded certificate(s) guarantees nothing but the parsed structure + // reaches the database - e.g. a private key bundled in the same PEM file + // is dropped rather than persisted. + certHex, err := readAndCanonicalizeLNDCert(setupRequest.LNDCertFile) + if err != nil { + // Return a generic error and log the detail server-side so the + // response is not a file existence/readability oracle. + logger.Logger.WithError(err).Error("Failed to process lnd cert file") + return errors.New("invalid LND certificate file") + } + err = api.cfg.SetUpdate("LNDCertHex", certHex, setupRequest.UnlockPassword) if err != nil { logger.Logger.WithError(err).Error("Failed to save lnd cert hex") return err } } - if setupRequest.LNDMacaroonHex != "" { - err = api.cfg.SetUpdate("LNDMacaroonHex", setupRequest.LNDMacaroonHex, setupRequest.UnlockPassword) + if setupRequest.LNDMacaroonFile != "" { + // The file path is provided by the (unauthenticated) setup request, so + // only persist the content if it parses as a macaroon. Storing the + // re-marshalled macaroon guarantees only the parsed structure reaches + // the database. + macaroonHex, err := readAndCanonicalizeLNDMacaroon(setupRequest.LNDMacaroonFile) + if err != nil { + // Return a generic error and log the detail server-side so the + // response is not a file existence/readability oracle. + logger.Logger.WithError(err).Error("Failed to process lnd macaroon file") + return errors.New("invalid LND macaroon file") + } + err = api.cfg.SetUpdate("LNDMacaroonHex", macaroonHex, setupRequest.UnlockPassword) if err != nil { logger.Logger.WithError(err).Error("Failed to save lnd macaroon hex") return err @@ -1444,16 +1870,145 @@ func (api *api) Setup(ctx context.Context, setupRequest *SetupRequest) error { } } + if setupRequest.CLNAddress != "" { + err = api.cfg.SetUpdate("CLNAddress", setupRequest.CLNAddress, setupRequest.UnlockPassword) + if err != nil { + logger.Logger.WithError(err).Error("Failed to save CLN address") + return err + } + } + + if setupRequest.CLNLightningDir != "" { + // The directory path is provided by the (unauthenticated) setup request. + // Validate that it holds the expected CLN TLS credentials before saving, + // so the path cannot be used as an existence/readability oracle for + // arbitrary directories (the failure otherwise surfaces via startupError + // on the anonymous /api/info response). + if err := validateCLNLightningDir(setupRequest.CLNLightningDir, setupRequest.CLNAddressHold != ""); err != nil { + logger.Logger.WithError(err).Error("Failed to validate CLN lightning directory") + return errors.New("invalid CLN lightning directory") + } + err = api.cfg.SetUpdate("CLNLightningDir", setupRequest.CLNLightningDir, setupRequest.UnlockPassword) + if err != nil { + logger.Logger.WithError(err).Error("Failed to save CLN Lightning directory path") + return err + } + } + + if setupRequest.CLNAddressHold != "" { + err = api.cfg.SetUpdate("CLNAddressHold", setupRequest.CLNAddressHold, setupRequest.UnlockPassword) + if err != nil { + logger.Logger.WithError(err).Error("Failed to save cln hold plugin address") + return err + } + } + + return nil +} + +// readAndCanonicalizeLNDCert reads the LND TLS certificate at the given path, +// validates that it contains at least one parseable certificate, and returns +// the hex-encoded re-encoding of only the parsed certificate(s). Any non +// CERTIFICATE PEM blocks (e.g. a bundled private key) are discarded so they are +// never persisted. Callers must not reflect the returned error to the client. +func readAndCanonicalizeLNDCert(path string) (string, error) { + raw, err := os.ReadFile(path) + if err != nil { + return "", fmt.Errorf("failed to read LND cert file: %w", err) + } + + var canonical []byte + rest := raw + for { + var block *pem.Block + block, rest = pem.Decode(rest) + if block == nil { + break + } + if block.Type != "CERTIFICATE" { + continue + } + cert, err := x509.ParseCertificate(block.Bytes) + if err != nil { + return "", fmt.Errorf("failed to parse LND certificate: %w", err) + } + canonical = append(canonical, pem.EncodeToMemory(&pem.Block{ + Type: "CERTIFICATE", + Bytes: cert.Raw, + })...) + } + if len(canonical) == 0 { + return "", errors.New("no valid certificate found in LND cert file") + } + + return hex.EncodeToString(canonical), nil +} + +// readAndCanonicalizeLNDMacaroon reads the LND macaroon at the given path, +// validates that it is a well-formed macaroon, and returns the hex-encoded +// re-marshalling so that only the parsed structure is persisted. Callers must +// not reflect the returned error to the client. +func readAndCanonicalizeLNDMacaroon(path string) (string, error) { + raw, err := os.ReadFile(path) + if err != nil { + return "", fmt.Errorf("failed to read LND macaroon file: %w", err) + } + + mac := &macaroon.Macaroon{} + if err := mac.UnmarshalBinary(raw); err != nil { + return "", fmt.Errorf("failed to parse LND macaroon: %w", err) + } + canonical, err := mac.MarshalBinary() + if err != nil { + return "", fmt.Errorf("failed to marshal LND macaroon: %w", err) + } + + return hex.EncodeToString(canonical), nil +} + +// validateCLNLightningDir checks that the given directory holds the CLN TLS +// credentials that will later be loaded at connect time (ca.pem, client.pem, +// client-key.pem), for each gRPC server name the config will use. This mirrors +// the parses performed by the CLN client's loadTLSCredentials so a directory +// that passes here is one CLN can actually use. Callers must not reflect the +// returned error to the client. +func validateCLNLightningDir(lightningDir string, hold bool) error { + // "cln" reads the directory directly; other server names are joined as a + // subdirectory, matching loadTLSCredentials in lnclient/cln. + serverNames := []string{"cln"} + if hold { + serverNames = append(serverNames, "hold") + } + + for _, serverName := range serverNames { + dir := lightningDir + if serverName != "cln" { + dir = filepath.Join(dir, serverName) + } + + caPEM, err := os.ReadFile(filepath.Join(dir, "ca.pem")) + if err != nil { + return fmt.Errorf("failed to read CLN CA cert (%s): %w", serverName, err) + } + if !x509.NewCertPool().AppendCertsFromPEM(caPEM) { + return fmt.Errorf("failed to parse CLN CA cert (%s)", serverName) + } + if _, err := tls.LoadX509KeyPair(filepath.Join(dir, "client.pem"), filepath.Join(dir, "client-key.pem")); err != nil { + return fmt.Errorf("failed to load CLN client cert/key (%s): %w", serverName, err) + } + } + return nil } func (api *api) GetWalletCapabilities(ctx context.Context) (*WalletCapabilitiesResponse, error) { - if api.svc.GetLNClient() == nil { - return nil, errors.New("LNClient not started") + lnClient := api.svc.GetLNClient() + if lnClient == nil { + return nil, ErrLNClientNotStarted } - methods := api.svc.GetLNClient().GetSupportedNIP47Methods() - notificationTypes := api.svc.GetLNClient().GetSupportedNIP47NotificationTypes() + methods := lnClient.GetSupportedNIP47Methods() + notificationTypes := lnClient.GetSupportedNIP47NotificationTypes() scopes, err := permissions.RequestMethodsToScopes(methods) if err != nil { @@ -1470,23 +2025,9 @@ func (api *api) GetWalletCapabilities(ctx context.Context) (*WalletCapabilitiesR }, nil } -func (api *api) SendPaymentProbes(ctx context.Context, sendPaymentProbesRequest *SendPaymentProbesRequest) (*SendPaymentProbesResponse, error) { - if api.svc.GetLNClient() == nil { - return nil, errors.New("LNClient not started") - } - - var errMessage string - err := api.svc.GetLNClient().SendPaymentProbes(ctx, sendPaymentProbesRequest.Invoice) - if err != nil { - errMessage = err.Error() - } - - return &SendPaymentProbesResponse{Error: errMessage}, nil -} - func (api *api) MigrateNodeStorage(ctx context.Context, to string) error { if api.svc.GetLNClient() == nil { - return errors.New("LNClient not started") + return ErrLNClientNotStarted } if to != "VSS" { return fmt.Errorf("migration type not supported: %s", to) @@ -1510,39 +2051,43 @@ func (api *api) MigrateNodeStorage(ctx context.Context, to string) error { return api.Stop() } -func (api *api) SendSpontaneousPaymentProbes(ctx context.Context, sendSpontaneousPaymentProbesRequest *SendSpontaneousPaymentProbesRequest) (*SendSpontaneousPaymentProbesResponse, error) { - if api.svc.GetLNClient() == nil { - return nil, errors.New("LNClient not started") - } - - var errMessage string - err := api.svc.GetLNClient().SendSpontaneousPaymentProbes(ctx, sendSpontaneousPaymentProbesRequest.Amount, sendSpontaneousPaymentProbesRequest.NodeId) - if err != nil { - errMessage = err.Error() - } - - return &SendSpontaneousPaymentProbesResponse{Error: errMessage}, nil -} - func (api *api) GetNetworkGraph(ctx context.Context, nodeIds []string) (NetworkGraphResponse, error) { - if api.svc.GetLNClient() == nil { - return nil, errors.New("LNClient not started") + lnClient := api.svc.GetLNClient() + if lnClient == nil { + return nil, ErrLNClientNotStarted } - return api.svc.GetLNClient().GetNetworkGraph(ctx, nodeIds) + return lnClient.GetNetworkGraph(ctx, nodeIds) } func (api *api) SyncWallet() error { - if api.svc.GetLNClient() == nil { - return errors.New("LNClient not started") + lnClient := api.svc.GetLNClient() + if lnClient == nil { + return ErrLNClientNotStarted } - api.svc.GetLNClient().UpdateLastWalletSyncRequest() + lnClient.UpdateLastWalletSyncRequest() return nil } -func (api *api) ListOnchainTransactions(ctx context.Context) ([]lnclient.OnchainTransaction, error) { - if api.svc.GetLNClient() == nil { - return nil, errors.New("LNClient not started") +func (api *api) ListOnchainTransactions(ctx context.Context) ([]OnchainTransaction, error) { + lnClient := api.svc.GetLNClient() + if lnClient == nil { + return nil, ErrLNClientNotStarted } - return api.svc.GetLNClient().ListOnchainTransactions(ctx) + transactions, err := lnClient.ListOnchainTransactions(ctx) + if err != nil { + return nil, err + } + apiTransactions := make([]OnchainTransaction, 0, len(transactions)) + for _, t := range transactions { + apiTransactions = append(apiTransactions, OnchainTransaction{ + AmountSat: t.AmountSat, + CreatedAt: t.CreatedAt, + State: t.State, + Type: t.Type, + NumConfirmations: t.NumConfirmations, + TxId: t.TxId, + }) + } + return apiTransactions, nil } func (api *api) GetLogOutput(ctx context.Context, logType string, getLogRequest *GetLogOutputRequest) (*GetLogOutputResponse, error) { @@ -1550,11 +2095,12 @@ func (api *api) GetLogOutput(ctx context.Context, logType string, getLogRequest var logData []byte if logType == LogTypeNode { - if api.svc.GetLNClient() == nil { - return nil, errors.New("LNClient not started") + lnClient := api.svc.GetLNClient() + if lnClient == nil { + return nil, ErrLNClientNotStarted } - logData, err = api.svc.GetLNClient().GetLogOutput(ctx, getLogRequest.MaxLen) + logData, err = lnClient.GetLogOutput(ctx, getLogRequest.MaxLen) if err != nil { return nil, err } @@ -1617,7 +2163,11 @@ func (api *api) Health(ctx context.Context) (*HealthResponse, error) { if lnClient != nil { nodeStatus, _ := lnClient.GetNodeStatus(ctx) if nodeStatus == nil || !nodeStatus.IsReady { - alarms = append(alarms, NewHealthAlarm(HealthAlarmKindNodeNotReady, nodeStatus)) + var apiNodeStatus *NodeStatus + if nodeStatus != nil { + apiNodeStatus = toApiNodeStatus(nodeStatus) + } + alarms = append(alarms, NewHealthAlarm(HealthAlarmKindNodeNotReady, apiNodeStatus)) } channels, err := lnClient.ListChannels(ctx) @@ -1646,7 +2196,7 @@ func (api *api) Health(ctx context.Context) (*HealthResponse, error) { func (api *api) GetCustomNodeCommands() (*CustomNodeCommandsResponse, error) { lnClient := api.svc.GetLNClient() if lnClient == nil { - return nil, errors.New("LNClient not started") + return nil, ErrLNClientNotStarted } allCommandDefs := lnClient.GetCustomNodeCommandDefinitions() @@ -1672,7 +2222,7 @@ func (api *api) GetCustomNodeCommands() (*CustomNodeCommandsResponse, error) { func (api *api) ExecuteCustomNodeCommand(ctx context.Context, command string) (interface{}, error) { lnClient := api.svc.GetLNClient() if lnClient == nil { - return nil, errors.New("LNClient not started") + return nil, ErrLNClientNotStarted } // Split command line into arguments. Command name must be the first argument. @@ -1758,19 +2308,21 @@ func (api *api) GetForwards() (*GetForwardsResponse, error) { return nil, err } - var totalOutboundAmount uint64 - var totalFeeEarned uint64 + var totalOutboundAmountMsat uint64 + var totalFeeEarnedMsat uint64 for _, forward := range forwards { - totalOutboundAmount += forward.OutboundAmountForwardedMsat - totalFeeEarned += forward.TotalFeeEarnedMsat + totalOutboundAmountMsat += forward.OutboundAmountForwardedMsat + totalFeeEarnedMsat += forward.TotalFeeEarnedMsat } numForwards := len(forwards) return &GetForwardsResponse{ - OutboundAmountForwardedMsat: totalOutboundAmount, - TotalFeeEarnedMsat: totalFeeEarned, + OutboundAmountForwardedSat: totalOutboundAmountMsat / 1000, + OutboundAmountForwardedMsat: totalOutboundAmountMsat, + TotalFeeEarnedSat: totalFeeEarnedMsat / 1000, + TotalFeeEarnedMsat: totalFeeEarnedMsat, NumForwards: uint64(numForwards), }, nil } diff --git a/api/apps_test.go b/api/apps_test.go index ff00783f..6cd69daa 100644 --- a/api/apps_test.go +++ b/api/apps_test.go @@ -9,6 +9,32 @@ import ( "github.com/stretchr/testify/require" ) +func TestBuildReturnToUrl(t *testing.T) { + relayUrls := []string{"wss://relay.getalby.com/v1"} + walletPubkey := "6f8bf1b7d58ac41b2c793837ba528c1d0a4a1cd2e3f5b7c9d0e1f2a3b4c5d6e7" + + assert.Equal(t, + "https://example.com?pubkey=6f8bf1b7d58ac41b2c793837ba528c1d0a4a1cd2e3f5b7c9d0e1f2a3b4c5d6e7&relay=wss%3A%2F%2Frelay.getalby.com%2Fv1", + buildReturnToUrl("https://example.com", relayUrls, walletPubkey, "", false)) + + // existing query parameters are preserved and lud16 is added + assert.Equal(t, + "https://example.com/path?foo=bar&lud16=user%40getalby.com&pubkey=6f8bf1b7d58ac41b2c793837ba528c1d0a4a1cd2e3f5b7c9d0e1f2a3b4c5d6e7&relay=wss%3A%2F%2Frelay.getalby.com%2Fv1", + buildReturnToUrl("https://example.com/path?foo=bar", relayUrls, walletPubkey, "user@getalby.com", false)) + + // isolated apps do not receive a lightning address + assert.Equal(t, + "http://example.com?pubkey=6f8bf1b7d58ac41b2c793837ba528c1d0a4a1cd2e3f5b7c9d0e1f2a3b4c5d6e7&relay=wss%3A%2F%2Frelay.getalby.com%2Fv1", + buildReturnToUrl("http://example.com", relayUrls, walletPubkey, "user@getalby.com", true)) + + // only http and https URLs are accepted + assert.Equal(t, "", buildReturnToUrl("", relayUrls, walletPubkey, "", false)) + assert.Equal(t, "", buildReturnToUrl("example.com/path", relayUrls, walletPubkey, "", false)) + assert.Equal(t, "", buildReturnToUrl("example://app", relayUrls, walletPubkey, "", false)) + assert.Equal(t, "", buildReturnToUrl("javascript:void(0)", relayUrls, walletPubkey, "", false)) + assert.Equal(t, "", buildReturnToUrl("::invalid::", relayUrls, walletPubkey, "", false)) +} + func TestCreateApp_SuperuserScopeIncorrectPassword(t *testing.T) { cfg := mocks.NewMockConfig(t) cfg.On("CheckUnlockPassword", "").Return(false) diff --git a/api/backup.go b/api/backup.go index 05146c46..32e5aa03 100644 --- a/api/backup.go +++ b/api/backup.go @@ -1,9 +1,11 @@ package api import ( + "bytes" "errors" "fmt" "io" + "math" "strings" "time" @@ -16,12 +18,48 @@ import ( "crypto/rand" "crypto/sha256" + "github.com/getAlby/hub/config" "github.com/getAlby/hub/db" "github.com/getAlby/hub/logger" "github.com/getAlby/hub/utils" "golang.org/x/crypto/pbkdf2" ) +// zipMagic is the ZIP local file header signature "PK\x03\x04" — the first +// four bytes of every ZIP file, and therefore of every archive produced by +// CreateBackup. decryptingReader uses it to detect which cipher scheme the +// backup file was created with. +var zipMagic = []byte{'P', 'K', 0x03, 0x04} + +// backupCipher describes one of the cipher schemes used for backup files, +// which are laid out as salt || iv || encrypted zip archive. +type backupCipher struct { + saltSize int + deriveKey func(password string, salt []byte) ([]byte, error) + newStream func(block cipher.Block, iv []byte) cipher.Stream +} + +var backupCiphers = []backupCipher{ + // current scheme, used for all new backup files + { + saltSize: 32, + deriveKey: func(password string, salt []byte) ([]byte, error) { + key, _, err := config.DeriveKey(password, salt) + return key, err + }, + newStream: cipher.NewCTR, + }, + // legacy scheme, kept to restore backup files created by older versions + { + saltSize: 8, + deriveKey: func(password string, salt []byte) ([]byte, error) { + return pbkdf2.Key([]byte(password), salt, 4096, 32, sha256.New), nil + }, + //nolint:staticcheck // OFB is required to read files created by older versions + newStream: cipher.NewOFB, + }, +} + func (api *api) CreateBackup(unlockPassword string, w io.Writer) error { logger.Logger.Info("Creating backup to migrate Alby Hub to another device") var err error @@ -38,8 +76,9 @@ func (api *api) CreateBackup(unlockPassword string, w io.Writer) error { return errors.New("Please disable auto-unlock before using this feature") } - if api.db.Dialector.Name() != "sqlite" { - return errors.New("Migration with non-sqlite backend is currently not supported") + dbBackend := api.db.Dialector.Name() + if dbBackend != "sqlite" && dbBackend != "postgres" { + return fmt.Errorf("migration with %s backend is currently not supported", dbBackend) } workDir, err := filepath.Abs(api.cfg.GetEnv().Workdir) @@ -49,17 +88,18 @@ func (api *api) CreateBackup(unlockPassword string, w io.Writer) error { lnStorageDir := "" - if api.svc.GetLNClient() == nil { + lnClient := api.svc.GetLNClient() + if lnClient == nil { return fmt.Errorf("node not running") } - lnStorageDir, err = api.svc.GetLNClient().GetStorageDir() + lnStorageDir, err = lnClient.GetStorageDir() if err != nil { return fmt.Errorf("failed to get storage dir: %w", err) } logger.Logger.WithField("path", lnStorageDir).Info("Found node storage dir") // Reset the routing data to decrease the LDK DB size - err = api.svc.GetLNClient().ResetRouter("ALL") + err = lnClient.ResetRouter("ALL") if err != nil { logger.Logger.WithError(err).Error("Failed to reset router") return fmt.Errorf("failed to reset router: %w", err) @@ -75,6 +115,50 @@ func (api *api) CreateBackup(unlockPassword string, w io.Writer) error { return errors.New("failed to remove oauth access token") } + // Locate the main database file. + dbFilePath := api.cfg.GetEnv().DatabaseUri + + if dbBackend == "postgres" { + // The migration file must contain a sqlite database, so copy the + // contents of the postgres database into a temporary sqlite database + // and add that to the archive instead. + dbFilePath = filepath.Join(workDir, "migration.db") + + removeConvertedDb := func() { + for _, path := range []string{dbFilePath, dbFilePath + "-wal", dbFilePath + "-shm"} { + if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) { + logger.Logger.WithError(err).WithField("path", path).Error("Failed to remove converted database file") + } + } + } + // Remove stale files from a previously failed migration attempt. + removeConvertedDb() + defer removeConvertedDb() + + logger.Logger.WithField("path", dbFilePath).Info("Copying postgres database to sqlite") + sqliteDb, err := db.NewDB(dbFilePath, api.cfg.GetEnv().LogDBQueries) + if err != nil { + logger.Logger.WithError(err).Error("Failed to create sqlite database for migration") + return fmt.Errorf("failed to create sqlite database for migration: %w", err) + } + + err = db.MigrateDB(api.db, sqliteDb) + if err != nil { + logger.Logger.WithError(err).Error("Failed to copy database contents to sqlite") + if stopErr := db.Stop(sqliteDb); stopErr != nil { + logger.Logger.WithError(stopErr).Error("Failed to stop sqlite database") + } + return fmt.Errorf("failed to copy database contents to sqlite: %w", err) + } + + // Close the sqlite database to checkpoint the WAL before archiving it. + err = db.Stop(sqliteDb) + if err != nil { + logger.Logger.WithError(err).Error("Failed to stop sqlite database") + return fmt.Errorf("failed to close sqlite database: %w", err) + } + } + // Closing the database leaves the service in an inconsistent state, // but that should not be a problem since the app is not expected // to be used after its data is exported. @@ -125,8 +209,6 @@ func (api *api) CreateBackup(unlockPassword string, w io.Writer) error { return err } - // Locate the main database file. - dbFilePath := api.cfg.GetEnv().DatabaseUri // Add the database file to the archive. logger.Logger.WithField("nwc.db", dbFilePath).Info("adding nwc db to zip") err = addFileToZip(dbFilePath, "nwc.db") @@ -151,8 +233,18 @@ func (api *api) CreateBackup(unlockPassword string, w io.Writer) error { } } + // Finalize the archive before reporting success; the deferred close + // only covers early returns. + err = zw.Close() + if err != nil { + logger.Logger.WithError(err).Error("Failed to finalize migration archive") + return fmt.Errorf("failed to finalize migration archive: %w", err) + } + logger.Logger.Info("Successfully created backup to migrate Alby Hub to another device") + api.nodeMigrationFileCreated.Store(true) + return nil } @@ -203,8 +295,38 @@ func (api *api) RestoreBackup(unlockPassword string, r io.Reader) error { return fmt.Errorf("failed to create zip reader: %w", err) } + if len(zr.File) == 0 { + return errors.New("backup file contains no files") + } + + restoreDir := filepath.Join(workDir, "restore") + + // Extract into a staging directory and only move it to the restore + // directory once every entry has been extracted, so that a failed + // extraction cannot leave a partial restore directory behind, which + // would be applied on the next startup. + stagingDir, err := os.MkdirTemp(workDir, "albyhub-restore-") + if err != nil { + return fmt.Errorf("failed to create staging directory: %w", err) + } + defer os.RemoveAll(stagingDir) + extractZipEntry := func(zipFile *zip.File) error { - fsFilePath := filepath.Join(workDir, "restore", filepath.FromSlash(zipFile.Name)) + // Entry names come from the archive and must not be trusted. Reject any + // name that is absolute or points outside the restore directory via + // ".." segments before joining it to a path. + entryName := filepath.FromSlash(zipFile.Name) + if !filepath.IsLocal(entryName) { + return fmt.Errorf("refusing to extract zip entry outside restore directory: %q", zipFile.Name) + } + + fsFilePath := filepath.Join(stagingDir, entryName) + + // Confirm the cleaned path is still contained within the staging + // directory. + if fsFilePath != stagingDir && !strings.HasPrefix(fsFilePath, stagingDir+string(os.PathSeparator)) { + return fmt.Errorf("refusing to extract zip entry outside restore directory: %q", zipFile.Name) + } if err = os.MkdirAll(filepath.Dir(fsFilePath), 0700); err != nil { return fmt.Errorf("failed to create directory for zip entry: %w", err) @@ -238,13 +360,20 @@ func (api *api) RestoreBackup(unlockPassword string, r io.Reader) error { } logger.Logger.WithField("count", len(zr.File)).Info("Extracted files") + if err = os.RemoveAll(restoreDir); err != nil { + return fmt.Errorf("failed to remove existing restore directory: %w", err) + } + if err = os.Rename(stagingDir, restoreDir); err != nil { + return fmt.Errorf("failed to move extracted files to restore directory: %w", err) + } + go func() { logger.Logger.Info("Backup restored. Shutting down Alby Hub...") api.svc.Shutdown() // ensure no -shm or -wal files exist as they will stop the restore for _, filename := range []string{"nwc.db", "nwc.db-shm", "nwc.db-wal"} { err = os.Remove(filepath.Join(workDir, filename)) - if err != nil { + if err != nil && !errors.Is(err, os.ErrNotExist) { logger.Logger.WithError(err).WithField("filename", filename).Error("failed to remove old nwc db file before restore") } } @@ -258,12 +387,17 @@ func (api *api) RestoreBackup(unlockPassword string, r io.Reader) error { } func encryptingWriter(w io.Writer, password string) (io.Writer, error) { - salt := make([]byte, 8) + scheme := backupCiphers[0] + + salt := make([]byte, scheme.saltSize) if _, err := rand.Read(salt); err != nil { return nil, fmt.Errorf("failed to generate salt: %w", err) } - encKey := pbkdf2.Key([]byte(password), salt, 4096, 32, sha256.New) + encKey, err := scheme.deriveKey(password, salt) + if err != nil { + return nil, fmt.Errorf("failed to derive encryption key: %w", err) + } block, err := aes.NewCipher(encKey) if err != nil { return nil, fmt.Errorf("failed to create AES cipher: %w", err) @@ -284,9 +418,8 @@ func encryptingWriter(w io.Writer, password string) (io.Writer, error) { return nil, fmt.Errorf("failed to write IV: %w", err) } - stream := cipher.NewOFB(block, iv) cw := &cipher.StreamWriter{ - S: stream, + S: scheme.newStream(block, iv), W: w, } @@ -294,27 +427,61 @@ func encryptingWriter(w io.Writer, password string) (io.Writer, error) { } func decryptingReader(r io.Reader, password string) (io.Reader, error) { - salt := make([]byte, 8) - if _, err := io.ReadFull(r, salt); err != nil { - return nil, fmt.Errorf("failed to read salt: %w", err) + // Read the largest possible header (salt, IV and the first bytes of the + // archive) upfront, then trial-decrypt with each supported cipher scheme + // and pick the one that produces the ZIP signature. + maxHeaderSize := 0 + minHeaderSize := math.MaxInt + for _, scheme := range backupCiphers { + headerSize := scheme.saltSize + aes.BlockSize + len(zipMagic) + maxHeaderSize = max(maxHeaderSize, headerSize) + minHeaderSize = min(minHeaderSize, headerSize) } - iv := make([]byte, aes.BlockSize) - if _, err := io.ReadFull(r, iv); err != nil { - return nil, fmt.Errorf("failed to read IV: %w", err) + // Read the full header with io.ReadFull rather than io.ReadAtLeast: the + // reader may deliver short reads (e.g. a network request body), and + // stopping early could truncate the header of a scheme with a larger + // salt. A short file is only acceptable if it still covers the smallest + // scheme header. + header := make([]byte, maxHeaderSize) + n, err := io.ReadFull(r, header) + if err != nil && !(errors.Is(err, io.ErrUnexpectedEOF) && n >= minHeaderSize) { + return nil, fmt.Errorf("failed to read backup header: %w", err) + } + header = header[:n] + + for _, scheme := range backupCiphers { + if len(header) < scheme.saltSize+aes.BlockSize+len(zipMagic) { + continue + } + salt := header[:scheme.saltSize] + iv := header[scheme.saltSize : scheme.saltSize+aes.BlockSize] + encrypted := header[scheme.saltSize+aes.BlockSize:] + + encKey, err := scheme.deriveKey(password, salt) + if err != nil { + return nil, fmt.Errorf("failed to derive encryption key: %w", err) + } + + block, err := aes.NewCipher(encKey) + if err != nil { + return nil, fmt.Errorf("failed to create AES cipher: %w", err) + } + + stream := scheme.newStream(block, iv) + decrypted := make([]byte, len(encrypted)) + stream.XORKeyStream(decrypted, encrypted) + if !bytes.Equal(decrypted[:len(zipMagic)], zipMagic) { + continue + } + + cr := &cipher.StreamReader{ + S: stream, + R: r, + } + + return io.MultiReader(bytes.NewReader(decrypted), cr), nil } - encKey := pbkdf2.Key([]byte(password), salt, 4096, 32, sha256.New) - block, err := aes.NewCipher(encKey) - if err != nil { - return nil, fmt.Errorf("failed to create AES cipher: %w", err) - } - - stream := cipher.NewOFB(block, iv) - cr := &cipher.StreamReader{ - S: stream, - R: r, - } - - return cr, nil + return nil, errors.New("invalid unlock password or backup file") } diff --git a/api/backup_test.go b/api/backup_test.go new file mode 100644 index 00000000..9ddfcc5a --- /dev/null +++ b/api/backup_test.go @@ -0,0 +1,261 @@ +package api + +import ( + "archive/zip" + "bytes" + "encoding/hex" + "io" + "os" + "path/filepath" + "strconv" + "strings" + "testing" + "testing/iotest" + + "github.com/sirupsen/logrus" + "github.com/stretchr/testify/require" + "gorm.io/datatypes" + + "github.com/getAlby/hub/config" + "github.com/getAlby/hub/db" + "github.com/getAlby/hub/logger" + test_db "github.com/getAlby/hub/tests/db" + "github.com/getAlby/hub/tests/mocks" +) + +// TestCreateBackup creates a backup from the test database (sqlite by +// default, postgres when TEST_DATABASE_URI is set) and verifies that the +// archive contains a valid sqlite database with the expected data. +func TestCreateBackup(t *testing.T) { + logger.Init(strconv.Itoa(int(logrus.DebugLevel))) + + workDir := t.TempDir() + + gormDB, err := test_db.NewDB(t) + require.NoError(t, err) + defer test_db.CloseDB(gormDB) + + appConfig := &config.AppConfig{ + Workdir: workDir, + DatabaseUri: test_db.GetTestDatabaseURI(), + } + cfg, err := config.NewConfig(appConfig, gormDB) + require.NoError(t, err) + + unlockPassword := "" + + // Represent a fully set-up hub: the unlock-password canary is written during + // setup and is required for the password check to pass. + require.NoError(t, cfg.SaveUnlockPasswordCheck(unlockPassword)) + + app := &db.App{ + Name: "test", + AppPubkey: "2b7dea2866958f17c568cf024e113db7a3baa9c253a9016889196b8d0b11c7ae", + Metadata: datatypes.JSON("{}"), + } + require.NoError(t, gormDB.Create(app).Error) + + lnClient := mocks.NewMockLNClient(t) + lnClient.On("GetStorageDir").Return("", nil) + lnClient.On("ResetRouter", "ALL").Return(nil) + + svc := mocks.NewMockService(t) + svc.On("GetLNClient").Return(lnClient) + svc.On("StopApp").Return() + + albyOAuthSvc := mocks.NewMockAlbyOAuthService(t) + albyOAuthSvc.On("RemoveOAuthAccessToken").Return(nil) + + theAPI := &api{ + db: gormDB, + cfg: cfg, + svc: svc, + albyOAuthSvc: albyOAuthSvc, + } + + var buf bytes.Buffer + err = theAPI.CreateBackup(unlockPassword, &buf) + require.NoError(t, err) + + // The temporary database created when converting from postgres must + // not be left behind in the working directory. + entries, err := os.ReadDir(workDir) + require.NoError(t, err) + require.Empty(t, entries) + + cr, err := decryptingReader(&buf, unlockPassword) + require.NoError(t, err) + decrypted, err := io.ReadAll(cr) + require.NoError(t, err) + + zr, err := zip.NewReader(bytes.NewReader(decrypted), int64(len(decrypted))) + require.NoError(t, err) + + dbFile, err := zr.Open("nwc.db") + require.NoError(t, err) + dbContents, err := io.ReadAll(dbFile) + require.NoError(t, err) + require.NoError(t, dbFile.Close()) + + restoredPath := filepath.Join(workDir, "restored.db") + require.NoError(t, os.WriteFile(restoredPath, dbContents, 0600)) + + restoredDB, err := db.NewDB(restoredPath, false) + require.NoError(t, err) + defer func() { + require.NoError(t, db.Stop(restoredDB)) + }() + + var restoredApp db.App + require.NoError(t, restoredDB.First(&restoredApp).Error) + require.Equal(t, app.Name, restoredApp.Name) + require.Equal(t, app.AppPubkey, restoredApp.AppPubkey) +} + +// TestRestoreBackupRejectsPathTraversal verifies that a backup archive +// containing an entry whose name points outside the restore directory is +// rejected and that no file is written outside it. +func TestRestoreBackupRejectsPathTraversal(t *testing.T) { + logger.Init(strconv.Itoa(int(logrus.DebugLevel))) + + gormDB, err := test_db.NewDB(t) + require.NoError(t, err) + defer test_db.CloseDB(gormDB) + + if gormDB.Dialector.Name() != "sqlite" { + t.Skip("restore is only supported on sqlite") + } + + workDir := t.TempDir() + + appConfig := &config.AppConfig{ + Workdir: workDir, + DatabaseUri: test_db.GetTestDatabaseURI(), + } + cfg, err := config.NewConfig(appConfig, gormDB) + require.NoError(t, err) + + theAPI := &api{ + db: gormDB, + cfg: cfg, + } + + unlockPassword := "" + + // The restore directory is /restore, so a "../" entry targets a + // file directly in the working directory, one level above it. + const escapeEntryName = "../pwned.txt" + escapeTarget := filepath.Join(workDir, "pwned.txt") + + var buf bytes.Buffer + cw, err := encryptingWriter(&buf, unlockPassword) + require.NoError(t, err) + zw := zip.NewWriter(cw) + // A valid entry before the malicious one, to verify that a partially + // extracted archive is not left behind when a later entry fails. + entryWriter, err := zw.Create("nwc.db") + require.NoError(t, err) + _, err = entryWriter.Write([]byte("backup contents")) + require.NoError(t, err) + entryWriter, err = zw.Create(escapeEntryName) + require.NoError(t, err) + _, err = entryWriter.Write([]byte("pwned")) + require.NoError(t, err) + require.NoError(t, zw.Close()) + + err = theAPI.RestoreBackup(unlockPassword, &buf) + require.ErrorContains(t, err, "refusing to extract zip entry outside restore directory") + + _, statErr := os.Stat(escapeTarget) + require.True(t, os.IsNotExist(statErr), "traversal entry must not be written outside the restore directory") + + // The failed restore must not leave a restore directory (which would be + // applied on the next startup) or any staging leftovers. + _, statErr = os.Stat(filepath.Join(workDir, "restore")) + require.True(t, os.IsNotExist(statErr), "failed restore must not leave a restore directory") + + entries, err := os.ReadDir(workDir) + require.NoError(t, err) + for _, entry := range entries { + require.False(t, strings.HasPrefix(entry.Name(), "albyhub-restore-"), "failed restore must not leave a staging directory") + } +} + +// legacyBackupFixture is a backup file created with the encryption scheme +// used by older versions (PBKDF2 key derivation), encrypted with the +// password "test-unlock-password". Its archive contains a single "nwc.db" +// entry with the contents "legacy backup contents". +const legacyBackupFixture = "0102030405060708101112131415161718191a1b1c1d1e1f8eca79631915f679a00cdd95d3f20d8d169eb9aa5d52642ca13b93886c3c7d7ba4b759462bc9dd8deccf638edcc9b5b9fda3d23dcd904cf6e99bc57ac59c4df6be5aa676542b7cbc9998029420c0ae5a6986c735150ababde5b382560acaebd5894aa4420924f1ced63fde570adc60c43b32e9e14a0ef60c379da5cac1be0000845992ea072ead036e336c7b859e8d018c4ef61667e3f520fe01" + +// TestDecryptingReaderLegacyBackup verifies that backup files created by +// older versions can still be decrypted. +func TestDecryptingReaderLegacyBackup(t *testing.T) { + encrypted, err := hex.DecodeString(legacyBackupFixture) + require.NoError(t, err) + + cr, err := decryptingReader(bytes.NewReader(encrypted), "test-unlock-password") + require.NoError(t, err) + + decrypted, err := io.ReadAll(cr) + require.NoError(t, err) + + zr, err := zip.NewReader(bytes.NewReader(decrypted), int64(len(decrypted))) + require.NoError(t, err) + + dbFile, err := zr.Open("nwc.db") + require.NoError(t, err) + dbContents, err := io.ReadAll(dbFile) + require.NoError(t, err) + require.NoError(t, dbFile.Close()) + require.Equal(t, "legacy backup contents", string(dbContents)) +} + +// TestDecryptingReaderFragmentedReader verifies that a backup file is +// decrypted correctly even when the reader delivers one byte at a time, +// which would truncate the header if it were not read in full. +func TestDecryptingReaderFragmentedReader(t *testing.T) { + var buf bytes.Buffer + cw, err := encryptingWriter(&buf, "test-unlock-password") + require.NoError(t, err) + + zw := zip.NewWriter(cw) + entryWriter, err := zw.Create("nwc.db") + require.NoError(t, err) + _, err = entryWriter.Write([]byte("backup contents")) + require.NoError(t, err) + require.NoError(t, zw.Close()) + + cr, err := decryptingReader(iotest.OneByteReader(bytes.NewReader(buf.Bytes())), "test-unlock-password") + require.NoError(t, err) + + decrypted, err := io.ReadAll(cr) + require.NoError(t, err) + + zr, err := zip.NewReader(bytes.NewReader(decrypted), int64(len(decrypted))) + require.NoError(t, err) + + dbFile, err := zr.Open("nwc.db") + require.NoError(t, err) + dbContents, err := io.ReadAll(dbFile) + require.NoError(t, err) + require.NoError(t, dbFile.Close()) + require.Equal(t, "backup contents", string(dbContents)) +} + +// TestDecryptingReaderWrongPassword verifies that decryption fails upfront +// when the password does not match the backup file. +func TestDecryptingReaderWrongPassword(t *testing.T) { + var buf bytes.Buffer + cw, err := encryptingWriter(&buf, "test-unlock-password") + require.NoError(t, err) + + zw := zip.NewWriter(cw) + entryWriter, err := zw.Create("nwc.db") + require.NoError(t, err) + _, err = entryWriter.Write([]byte("backup contents")) + require.NoError(t, err) + require.NoError(t, zw.Close()) + + _, err = decryptingReader(bytes.NewReader(buf.Bytes()), "wrong-password") + require.Error(t, err) +} diff --git a/api/esplora.go b/api/esplora.go index 791aef4a..510e51c3 100644 --- a/api/esplora.go +++ b/api/esplora.go @@ -46,6 +46,15 @@ func (api *api) RequestEsploraApi(ctx context.Context, endpoint string) (interfa return nil, errors.New("failed to read response body") } + if res.StatusCode != http.StatusOK { + logger.Logger.WithFields(logrus.Fields{ + "endpoint": endpoint, + "status_code": res.StatusCode, + "body": string(body), + }).Error("Esplora endpoint returned non-success code") + return nil, fmt.Errorf("esplora endpoint returned non-success code: %s", string(body)) + } + var jsonContent interface{} jsonErr := json.Unmarshal(body, &jsonContent) if jsonErr != nil { diff --git a/api/lsp.go b/api/lsp.go index 7d407949..b1742d09 100644 --- a/api/lsp.go +++ b/api/lsp.go @@ -17,9 +17,9 @@ import ( ) func (api *api) RequestLSPOrder(ctx context.Context, request *LSPOrderRequest) (*LSPOrderResponse, error) { - - if api.svc.GetLNClient() == nil { - return nil, errors.New("LNClient not started") + lnClient := api.svc.GetLNClient() + if lnClient == nil { + return nil, ErrLNClientNotStarted } if request.LSPType != lsp.LSP_TYPE_LSPS1 { @@ -28,7 +28,7 @@ func (api *api) RequestLSPOrder(ctx context.Context, request *LSPOrderRequest) ( logger.Logger.Info("Requesting own node info") - nodeInfo, err := api.svc.GetLNClient().GetInfo(ctx) + nodeInfo, err := lnClient.GetInfo(ctx) if err != nil { logger.Logger.WithError(err).WithFields(logrus.Fields{ "lspIdentifier": request.LSPIdentifier, @@ -46,7 +46,7 @@ func (api *api) RequestLSPOrder(ctx context.Context, request *LSPOrderRequest) ( logger.Logger.WithField("lspInfo", lspInfo).Info("Connecting to LSP node as a peer") - err = api.svc.GetLNClient().ConnectPeer(ctx, &lnclient.ConnectPeerRequest{ + err = lnClient.ConnectPeer(ctx, &lnclient.ConnectPeerRequest{ Pubkey: lspInfo.Pubkey, Address: lspInfo.Address, Port: lspInfo.Port, @@ -57,9 +57,13 @@ func (api *api) RequestLSPOrder(ctx context.Context, request *LSPOrderRequest) ( return nil, err } - invoice, fee, err := api.requestLSPS1Invoice(ctx, request, nodeInfo.Network, nodeInfo.Pubkey, lspInfo.MaxChannelExpiryBlocks, lspInfo.MinRequiredChannelConfirmations, lspInfo.MinFundingConfirmsWithinBlocks) - invoiceAmount := uint64(0) - incomingLiquidity := request.Amount + invoice, feeSat, err := api.requestLSPS1Invoice(ctx, lnClient, request, nodeInfo.Network, nodeInfo.Pubkey, lspInfo.MaxChannelExpiryBlocks, lspInfo.MinRequiredChannelConfirmations, lspInfo.MinFundingConfirmsWithinBlocks) + invoiceAmountSat := uint64(0) + incomingLiquiditySat := uint64(0) + resolvedAmountSat := ResolveToSat(request.AmountSat, nil, request.Amount, nil) + if resolvedAmountSat != nil { + incomingLiquiditySat = *resolvedAmountSat + } if err != nil { logger.Logger.WithError(err).Error("Failed to request invoice") @@ -73,15 +77,19 @@ func (api *api) RequestLSPOrder(ctx context.Context, request *LSPOrderRequest) ( return nil, err } - invoiceAmount = uint64(paymentRequest.MSatoshi / 1000) + invoiceAmountSat = uint64(paymentRequest.MSatoshi / 1000) } newChannelResponse := &LSPOrderResponse{ - Invoice: invoice, - Fee: fee, - InvoiceAmount: invoiceAmount, - IncomingLiquidity: incomingLiquidity, - OutgoingLiquidity: uint64(0), // JIT channel no longer supported + Invoice: invoice, + Fee: feeSat, + FeeSat: feeSat, + InvoiceAmount: invoiceAmountSat, + InvoiceAmountSat: invoiceAmountSat, + IncomingLiquidity: incomingLiquiditySat, + IncomingLiquiditySat: incomingLiquiditySat, + OutgoingLiquidity: uint64(0), + OutgoingLiquiditySat: uint64(0), } logger.Logger.WithFields(logrus.Fields{ @@ -91,8 +99,8 @@ func (api *api) RequestLSPOrder(ctx context.Context, request *LSPOrderRequest) ( return newChannelResponse, nil } -func (api *api) requestLSPS1Invoice(ctx context.Context, request *LSPOrderRequest, network, pubkey string, channelExpiryBlocks uint64, minRequiredChannelConfirmations uint64, minFundingConfirmsWithinBlocks uint64) (invoice string, fee uint64, err error) { - refundAddress, err := api.svc.GetLNClient().GetNewOnchainAddress(ctx) +func (api *api) requestLSPS1Invoice(ctx context.Context, lnClient lnclient.LNClient, request *LSPOrderRequest, network, pubkey string, channelExpiryBlocks uint64, minRequiredChannelConfirmations uint64, minFundingConfirmsWithinBlocks uint64) (invoice string, feeSat uint64, err error) { + refundAddress, err := lnClient.GetNewOnchainAddress(ctx) if err != nil { logger.Logger.WithError(err).Error("Failed to request onchain address") return "", 0, err @@ -136,9 +144,16 @@ func (api *api) requestLSPS1Invoice(ctx context.Context, request *LSPOrderReques token = "AlbyHub/" + version.Tag } + amountSat := uint64(0) + resolvedAmountSat := ResolveToSat(request.AmountSat, nil, request.Amount, nil) + if resolvedAmountSat != nil { + amountSat = *resolvedAmountSat + } + lspBalanceSat := strconv.FormatUint(amountSat, 10) + lsps1ChannelRequest := &alby.LSPChannelRequest{ PublicKey: pubkey, - LSPBalanceSat: strconv.FormatUint(request.Amount, 10), + LSPBalanceSat: lspBalanceSat, ClientBalanceSat: "0", RequiredChannelConfirmations: requiredChannelConfirmations, FundingConfirmsWithinBlocks: minFundingConfirmsWithinBlocks, @@ -155,7 +170,7 @@ func (api *api) requestLSPS1Invoice(ctx context.Context, request *LSPOrderReques if channelResponse.Payment != nil { invoice = channelResponse.Payment.Bolt11.Invoice - fee, err = strconv.ParseUint(channelResponse.Payment.Bolt11.FeeTotalSat, 10, 64) + feeSat, err = strconv.ParseUint(channelResponse.Payment.Bolt11.FeeTotalSat, 10, 64) if err != nil { logger.Logger.WithError(err).WithFields(logrus.Fields{ "lspIdentifier": request.LSPIdentifier, @@ -164,5 +179,5 @@ func (api *api) requestLSPS1Invoice(ctx context.Context, request *LSPOrderReques } } - return invoice, fee, nil + return invoice, feeSat, nil } diff --git a/api/models.go b/api/models.go index 971d80d1..355e6602 100644 --- a/api/models.go +++ b/api/models.go @@ -2,34 +2,35 @@ package api import ( "context" + "errors" "io" "time" "github.com/getAlby/hub/alby" "github.com/getAlby/hub/db" - "github.com/getAlby/hub/lnclient" "github.com/getAlby/hub/swaps" ) type API interface { CreateApp(createAppRequest *CreateAppRequest) (*CreateAppResponse, error) UpdateApp(app *db.App, updateAppRequest *UpdateAppRequest) error - Transfer(ctx context.Context, fromAppId *uint, toAppId *uint, amountMsat uint64) error + Transfer(ctx context.Context, fromAppId *uint, toAppId *uint, amountMsat uint64, description string) error DeleteApp(app *db.App) error - GetApp(app *db.App) *App + GetApp(app *db.App) (*App, error) ListApps(limit uint64, offset uint64, filters ListAppsFilters, orderBy string) (*ListAppsResponse, error) CreateLightningAddress(ctx context.Context, createLightningAddressRequest *CreateLightningAddressRequest) error DeleteLightningAddress(ctx context.Context, appId uint) error ListChannels(ctx context.Context) ([]Channel, error) GetChannelPeerSuggestions(ctx context.Context) ([]alby.ChannelPeerSuggestion, error) + GetStories(ctx context.Context) ([]alby.Story, error) GetLSPChannelOffer(ctx context.Context) (*alby.LSPChannelOffer, error) ResetRouter(key string) error ChangeUnlockPassword(changeUnlockPasswordRequest *ChangeUnlockPasswordRequest) error SetAutoUnlockPassword(unlockPassword string) error Stop() error - GetNodeConnectionInfo(ctx context.Context) (*lnclient.NodeConnectionInfo, error) - GetNodeStatus(ctx context.Context) (*lnclient.NodeStatus, error) - ListPeers(ctx context.Context) ([]lnclient.PeerDetails, error) + GetNodeConnectionInfo(ctx context.Context) (*NodeConnectionInfo, error) + GetNodeStatus(ctx context.Context) (*NodeStatus, error) + ListPeers(ctx context.Context) ([]PeerDetails, error) ConnectPeer(ctx context.Context, connectPeerRequest *ConnectPeerRequest) error DisconnectPeer(ctx context.Context, peerId string) error OpenChannel(ctx context.Context, openChannelRequest *OpenChannelRequest) (*OpenChannelResponse, error) @@ -40,21 +41,20 @@ type API interface { GetNewOnchainAddress(ctx context.Context) (string, error) GetUnusedOnchainAddress(ctx context.Context) (string, error) SignMessage(ctx context.Context, message string) (*SignMessageResponse, error) - RedeemOnchainFunds(ctx context.Context, toAddress string, amount uint64, feeRate *uint64, sendAll bool) (*RedeemOnchainFundsResponse, error) + RedeemOnchainFunds(ctx context.Context, toAddress string, amountSat uint64, feeRate *uint64, sendAll bool) (*RedeemOnchainFundsResponse, error) GetBalances(ctx context.Context) (*BalancesResponse, error) - ListTransactions(ctx context.Context, appId *uint, limit uint64, offset uint64) (*ListTransactionsResponse, error) - ListOnchainTransactions(ctx context.Context) ([]lnclient.OnchainTransaction, error) - SendPayment(ctx context.Context, invoice string, amountMsat *uint64, metadata map[string]interface{}) (*SendPaymentResponse, error) - CreateInvoice(ctx context.Context, amount uint64, description string) (*MakeInvoiceResponse, error) + ListTransactions(ctx context.Context, appId *uint, limit uint64, offset uint64, filters ListTransactionsFilters) (*ListTransactionsResponse, error) + ListOnchainTransactions(ctx context.Context) ([]OnchainTransaction, error) + SendPayment(ctx context.Context, invoice string, amountMsat *uint64, metadata map[string]interface{}, fromAppId *uint) (*SendPaymentResponse, error) + CreateInvoice(ctx context.Context, amountMsat uint64, description string, toAppId *uint) (*MakeInvoiceResponse, error) LookupInvoice(ctx context.Context, paymentHash string) (*LookupInvoiceResponse, error) + SetTransactionUserLabels(ctx context.Context, id uint, labels map[string]string) error RequestMempoolApi(ctx context.Context, endpoint string) (interface{}, error) GetInfo(ctx context.Context) (*InfoResponse, error) GetMnemonic(unlockPassword string) (*MnemonicResponse, error) SetNextBackupReminder(backupReminderRequest *BackupReminderRequest) error Start(startRequest *StartRequest) Setup(ctx context.Context, setupRequest *SetupRequest) error - SendPaymentProbes(ctx context.Context, sendPaymentProbesRequest *SendPaymentProbesRequest) (*SendPaymentProbesResponse, error) - SendSpontaneousPaymentProbes(ctx context.Context, sendSpontaneousPaymentProbesRequest *SendSpontaneousPaymentProbesRequest) (*SendSpontaneousPaymentProbesResponse, error) GetNetworkGraph(ctx context.Context, nodeIds []string) (NetworkGraphResponse, error) SyncWallet() error GetLogOutput(ctx context.Context, logType string, getLogRequest *GetLogOutputRequest) (*GetLogOutputResponse, error) @@ -64,8 +64,6 @@ type API interface { MigrateNodeStorage(ctx context.Context, to string) error GetWalletCapabilities(ctx context.Context) (*WalletCapabilitiesResponse, error) Health(ctx context.Context) (*HealthResponse, error) - SetCurrency(currency string) error - SetBitcoinDisplayFormat(format string) error UpdateSettings(updateSettingsRequest *UpdateSettingsRequest) error LookupSwap(swapId string) (*LookupSwapResponse, error) ListSwaps() (*ListSwapsResponse, error) @@ -85,24 +83,33 @@ type API interface { GetForwards() (*GetForwardsResponse, error) } +var ErrLNClientNotStarted = errors.New("LNClient not started") + type App struct { - ID uint `json:"id"` - Name string `json:"name"` - Description string `json:"description"` - AppPubkey string `json:"appPubkey"` - CreatedAt time.Time `json:"createdAt"` - UpdatedAt time.Time `json:"updatedAt"` - LastUsedAt *time.Time `json:"lastUsedAt"` - ExpiresAt *time.Time `json:"expiresAt"` - Scopes []string `json:"scopes"` - MaxAmountSat uint64 `json:"maxAmount"` - BudgetUsage uint64 `json:"budgetUsage"` - BudgetRenewal string `json:"budgetRenewal"` - Isolated bool `json:"isolated"` - WalletPubkey string `json:"walletPubkey"` - UniqueWalletPubkey bool `json:"uniqueWalletPubkey"` - Balance int64 `json:"balance"` - Metadata Metadata `json:"metadata,omitempty"` + ID uint `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + AppPubkey string `json:"appPubkey"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` + LastUsedAt *time.Time `json:"lastUsedAt"` + LastSettledTransactionAt *time.Time `json:"lastSettledTransactionAt"` + ExpiresAt *time.Time `json:"expiresAt"` + Scopes []string `json:"scopes"` + MaxAmount uint64 `json:"maxAmount"` // deprecated + MaxAmountSat uint64 `json:"maxAmountSat"` + MaxAmountMsat uint64 `json:"maxAmountMsat"` + BudgetUsage uint64 `json:"budgetUsage"` // deprecated + BudgetUsageSat uint64 `json:"budgetUsageSat"` + BudgetUsageMsat uint64 `json:"budgetUsageMsat"` + BudgetRenewal string `json:"budgetRenewal"` + Isolated bool `json:"isolated"` + WalletPubkey string `json:"walletPubkey"` + UniqueWalletPubkey bool `json:"uniqueWalletPubkey"` + Balance int64 `json:"balance"` // deprecated + BalanceSat int64 `json:"balanceSat"` + BalanceMsat int64 `json:"balanceMsat"` + Metadata Metadata `json:"metadata,omitempty"` } type ListAppsFilters struct { @@ -113,13 +120,18 @@ type ListAppsFilters struct { } type ListAppsResponse struct { - Apps []App `json:"apps"` - TotalCount uint64 `json:"totalCount"` + Apps []App `json:"apps"` + TotalCount uint64 `json:"totalCount"` + TotalBalance *int64 `json:"totalBalance,omitempty"` // deprecated + TotalBalanceSat *int64 `json:"totalBalanceSat,omitempty"` + TotalBalanceMsat *int64 `json:"totalBalanceMsat,omitempty"` } type UpdateAppRequest struct { Name *string `json:"name"` - MaxAmountSat *uint64 `json:"maxAmount"` + MaxAmount *uint64 `json:"maxAmount"` // deprecated + MaxAmountSat *uint64 `json:"maxAmountSat"` + MaxAmountMsat *uint64 `json:"maxAmountMsat"` BudgetRenewal *string `json:"budgetRenewal"` ExpiresAt *string `json:"expiresAt"` UpdateExpiresAt bool `json:"updateExpiresAt"` @@ -129,15 +141,19 @@ type UpdateAppRequest struct { } type TransferRequest struct { - AmountSat uint64 `json:"amountSat"` - FromAppId *uint `json:"fromAppId"` - ToAppId *uint `json:"toAppId"` + AmountSat *uint64 `json:"amountSat"` + AmountMsat *uint64 `json:"amountMsat"` + FromAppId *uint `json:"fromAppId"` + ToAppId *uint `json:"toAppId"` + Description string `json:"description"` } type CreateAppRequest struct { Name string `json:"name"` Pubkey string `json:"pubkey"` - MaxAmountSat uint64 `json:"maxAmount"` + MaxAmount *uint64 `json:"maxAmount"` // deprecated + MaxAmountSat *uint64 `json:"maxAmountSat"` + MaxAmountMsat *uint64 `json:"maxAmountMsat"` BudgetRenewal string `json:"budgetRenewal"` ExpiresAt string `json:"expiresAt"` Scopes []string `json:"scopes"` @@ -153,8 +169,9 @@ type CreateLightningAddressRequest struct { } type InitiateSwapRequest struct { - SwapAmount uint64 `json:"swapAmount"` - Destination string `json:"destination"` + SwapAmount *uint64 `json:"swapAmount"` // deprecated + SwapAmountSat *uint64 `json:"swapAmountSat"` + Destination string `json:"destination"` } type RefundSwapRequest struct { @@ -163,25 +180,34 @@ type RefundSwapRequest struct { } type EnableAutoSwapRequest struct { - BalanceThreshold uint64 `json:"balanceThreshold"` - SwapAmount uint64 `json:"swapAmount"` - Destination string `json:"destination"` + BalanceThreshold *uint64 `json:"balanceThreshold"` // deprecated + BalanceThresholdSat *uint64 `json:"balanceThresholdSat"` + SwapAmount *uint64 `json:"swapAmount"` // deprecated + SwapAmountSat *uint64 `json:"swapAmountSat"` + Destination string `json:"destination"` + DestinationType string `json:"destinationType"` + UnlockPassword string `json:"unlockPassword"` } type GetAutoSwapConfigResponse struct { - Type string `json:"type"` - Enabled bool `json:"enabled"` - BalanceThreshold uint64 `json:"balanceThreshold"` - SwapAmount uint64 `json:"swapAmount"` - Destination string `json:"destination"` + Type string `json:"type"` + Enabled bool `json:"enabled"` + BalanceThreshold uint64 `json:"balanceThreshold"` // deprecated + BalanceThresholdSat uint64 `json:"balanceThresholdSat"` + SwapAmount uint64 `json:"swapAmount"` // deprecated + SwapAmountSat uint64 `json:"swapAmountSat"` + Destination string `json:"destination"` } type SwapInfoResponse struct { - AlbyServiceFee float64 `json:"albyServiceFee"` - BoltzServiceFee float64 `json:"boltzServiceFee"` - BoltzNetworkFee uint64 `json:"boltzNetworkFee"` - MinAmount uint64 `json:"minAmount"` - MaxAmount uint64 `json:"maxAmount"` + AlbyServiceFee float64 `json:"albyServiceFee"` + BoltzServiceFee float64 `json:"boltzServiceFee"` + BoltzNetworkFee uint64 `json:"boltzNetworkFee"` // deprecated + BoltzNetworkFeeSat uint64 `json:"boltzNetworkFeeSat"` + MinAmount uint64 `json:"minAmount"` // deprecated + MinAmountSat uint64 `json:"minAmountSat"` + MaxAmount uint64 `json:"maxAmount"` // deprecated + MaxAmountSat uint64 `json:"maxAmountSat"` } type ListSwapsResponse struct { @@ -195,8 +221,10 @@ type Swap struct { Type string `json:"type"` State string `json:"state"` Invoice string `json:"invoice"` - SendAmount uint64 `json:"sendAmount"` - ReceiveAmount uint64 `json:"receiveAmount"` + SendAmount uint64 `json:"sendAmount"` // deprecated + SendAmountSat uint64 `json:"sendAmountSat"` + ReceiveAmount uint64 `json:"receiveAmount"` // deprecated + ReceiveAmountSat uint64 `json:"receiveAmountSat"` PaymentHash string `json:"paymentHash"` DestinationAddress string `json:"destinationAddress"` RefundAddress string `json:"refundAddress"` @@ -240,8 +268,6 @@ type SetupRequest struct { LNDAddress string `json:"lndAddress"` LNDCertFile string `json:"lndCertFile"` LNDMacaroonFile string `json:"lndMacaroonFile"` - LNDCertHex string `json:"lndCertHex"` - LNDMacaroonHex string `json:"lndMacaroonHex"` // Phoenixd fields PhoenixdAddress string `json:"phoenixdAddress"` @@ -249,6 +275,11 @@ type SetupRequest struct { // Cashu fields CashuMintUrl string `json:"cashuMintUrl"` + + // CLN fields + CLNAddress string `json:"clnAddress"` + CLNLightningDir string `json:"clnLightningDir"` + CLNAddressHold string `json:"clnAddressHold"` } type CreateAppResponse struct { @@ -273,35 +304,47 @@ type InfoResponseRelay struct { } type InfoResponse struct { - BackendType string `json:"backendType"` - SetupCompleted bool `json:"setupCompleted"` - OAuthRedirect bool `json:"oauthRedirect"` - Running bool `json:"running"` - Unlocked bool `json:"unlocked"` - AlbyAuthUrl string `json:"albyAuthUrl"` - NextBackupReminder string `json:"nextBackupReminder"` - AlbyUserIdentifier string `json:"albyUserIdentifier"` - AlbyAccountConnected bool `json:"albyAccountConnected"` - Version string `json:"version"` - Network string `json:"network"` - EnableAdvancedSetup bool `json:"enableAdvancedSetup"` - LdkVssEnabled bool `json:"ldkVssEnabled"` - VssSupported bool `json:"vssSupported"` - StartupState string `json:"startupState"` - StartupError string `json:"startupError"` - StartupErrorTime time.Time `json:"startupErrorTime"` - AutoUnlockPasswordSupported bool `json:"autoUnlockPasswordSupported"` - AutoUnlockPasswordEnabled bool `json:"autoUnlockPasswordEnabled"` - Currency string `json:"currency"` - BitcoinDisplayFormat string `json:"bitcoinDisplayFormat"` - Relays []InfoResponseRelay `json:"relays"` - NodeAlias string `json:"nodeAlias"` - MempoolUrl string `json:"mempoolUrl"` + BackendType string `json:"backendType"` + SetupCompleted bool `json:"setupCompleted"` + OAuthRedirect bool `json:"oauthRedirect"` + Running bool `json:"running"` + Unlocked bool `json:"unlocked"` + AlbyAuthUrl string `json:"albyAuthUrl"` + NextBackupReminder string `json:"nextBackupReminder"` + AlbyUserIdentifier string `json:"albyUserIdentifier"` + AlbyAccountConnected bool `json:"albyAccountConnected"` + Version string `json:"version"` + Network string `json:"network"` + EnableAdvancedSetup bool `json:"enableAdvancedSetup"` + LdkVssEnabled bool `json:"ldkVssEnabled"` + LdkVssUrl string `json:"ldkVssUrl"` + VssSupported bool `json:"vssSupported"` + DatabaseType string `json:"databaseType"` + StartupState string `json:"startupState"` + StartupError string `json:"startupError"` + StartupErrorTime time.Time `json:"startupErrorTime"` + AutoUnlockPasswordSupported bool `json:"autoUnlockPasswordSupported"` + AutoUnlockPasswordEnabled bool `json:"autoUnlockPasswordEnabled"` + Currency string `json:"currency"` + BitcoinDisplayFormat string `json:"bitcoinDisplayFormat"` + Relays []InfoResponseRelay `json:"relays"` + NodeAlias string `json:"nodeAlias"` + MempoolUrl string `json:"mempoolUrl"` + ChainDataSourceType string `json:"chainDataSourceType,omitempty"` + ChainDataSourceAddress string `json:"chainDataSourceAddress,omitempty"` + JitChannelsLiquiditySource string `json:"jitChannelsLiquiditySource,omitempty"` + JitChannelsMinPaymentSizeMsat *uint64 `json:"jitChannelsMinPaymentSizeMsat,omitempty"` + JitChannelsMaxPaymentSizeMsat *uint64 `json:"jitChannelsMaxPaymentSizeMsat,omitempty"` + JitChannelsEnabled bool `json:"jitChannelsEnabled"` + HideUpdateBanner bool `json:"hideUpdateBanner"` + SupportsBolt12 bool `json:"supportsBolt12"` + NodeMigrationFileCreated bool `json:"nodeMigrationFileCreated"` } type UpdateSettingsRequest struct { Currency string `json:"currency"` BitcoinDisplayFormat string `json:"bitcoinDisplayFormat"` + JitChannelsEnabled *bool `json:"jitChannelsEnabled"` } type SetNodeAliasRequest struct { @@ -324,23 +367,83 @@ type AutoUnlockRequest struct { UnlockPassword string `json:"unlockPassword"` } -type ConnectPeerRequest = lnclient.ConnectPeerRequest -type OpenChannelRequest = lnclient.OpenChannelRequest -type OpenChannelResponse = lnclient.OpenChannelResponse -type CloseChannelResponse = lnclient.CloseChannelResponse -type UpdateChannelRequest = lnclient.UpdateChannelRequest +type ConnectPeerRequest struct { + Pubkey string `json:"pubkey"` + Address string `json:"address"` + Port uint16 `json:"port"` +} + +type OpenChannelRequest struct { + Pubkey string `json:"pubkey"` + AmountSats int64 `json:"amountSats"` + Public bool `json:"public"` +} + +type OpenChannelResponse struct { + FundingTxId string `json:"fundingTxId"` +} + +type CloseChannelResponse struct { +} + +type UpdateChannelRequest struct { + ChannelId string `json:"channelId"` + NodeId string `json:"nodeId"` + ForwardingFeeBaseMsat uint32 `json:"forwardingFeeBaseMsat"` + ForwardingFeeProportionalMillionths uint32 `json:"forwardingFeeProportionalMillionths"` + MaxDustHtlcExposureFromFeeRateMultiplier uint64 `json:"maxDustHtlcExposureFromFeeRateMultiplier"` +} + +type NodeConnectionInfo struct { + Pubkey string `json:"pubkey"` + Address string `json:"address"` + Port int `json:"port"` +} + +type NodeStatus struct { + IsReady bool `json:"isReady"` + InternalNodeStatus interface{} `json:"internalNodeStatus"` +} + +type PeerDetails struct { + NodeId string `json:"nodeId"` + Address string `json:"address"` + IsPersisted bool `json:"isPersisted"` + IsConnected bool `json:"isConnected"` +} + +type OnchainTransaction struct { + AmountSat uint64 `json:"amountSat"` + CreatedAt uint64 `json:"createdAt"` + State string `json:"state"` + Type string `json:"type"` + NumConfirmations uint32 `json:"numConfirmations"` + TxId string `json:"txId"` +} + +type PendingBalanceDetails struct { + ChannelId string `json:"channelId"` + NodeId string `json:"nodeId"` + Amount uint64 `json:"amount"` // deprecated + AmountSat uint64 `json:"amountSat"` + FundingTxId string `json:"fundingTxId"` + FundingTxVout uint32 `json:"fundingTxVout"` +} type RebalanceChannelRequest struct { - ReceiveThroughNodePubkey string `json:"receiveThroughNodePubkey"` - AmountSat uint64 `json:"amountSat"` + ReceiveThroughNodePubkey string `json:"receiveThroughNodePubkey"` + AmountSat *uint64 `json:"amountSat"` + AmountMsat *uint64 `json:"amountMsat"` } type RebalanceChannelResponse struct { - TotalFeeSat uint64 `json:"totalFeeSat"` + TotalFeeSat uint64 `json:"totalFeeSat"` + TotalFeeMsat uint64 `json:"totalFeeMsat"` } type RedeemOnchainFundsRequest struct { ToAddress string `json:"toAddress"` - Amount uint64 `json:"amount"` + Amount *uint64 `json:"amount"` // deprecated + AmountSat *uint64 `json:"amountSat"` FeeRate *uint64 `json:"feeRate"` SendAll bool `json:"sendAll"` } @@ -349,13 +452,61 @@ type RedeemOnchainFundsResponse struct { TxId string `json:"txId"` } -type OnchainBalanceResponse = lnclient.OnchainBalanceResponse -type BalancesResponse = lnclient.BalancesResponse +type OnchainBalanceResponse struct { + Spendable int64 `json:"spendable"` // deprecated + SpendableSat int64 `json:"spendableSat"` + Total int64 `json:"total"` // deprecated + TotalSat int64 `json:"totalSat"` + Reserved int64 `json:"reserved"` // deprecated + ReservedSat int64 `json:"reservedSat"` + PendingBalancesFromChannelClosures uint64 `json:"pendingBalancesFromChannelClosures"` // deprecated + PendingBalancesFromChannelClosuresSat uint64 `json:"pendingBalancesFromChannelClosuresSat"` + PendingBalancesDetails []PendingBalanceDetails `json:"pendingBalancesDetails"` + PendingSweepBalancesDetails []PendingBalanceDetails `json:"pendingSweepBalancesDetails"` + InternalBalances interface{} `json:"internalBalances"` +} + +type LightningBalanceResponse struct { + TotalSpendable int64 `json:"totalSpendable"` // deprecated + TotalSpendableSat int64 `json:"totalSpendableSat"` + TotalSpendableMsat int64 `json:"totalSpendableMsat"` + TotalReceivable int64 `json:"totalReceivable"` // deprecated + TotalReceivableSat int64 `json:"totalReceivableSat"` + TotalReceivableMsat int64 `json:"totalReceivableMsat"` + NextMaxSpendable int64 `json:"nextMaxSpendable"` // deprecated + NextMaxSpendableSat int64 `json:"nextMaxSpendableSat"` + NextMaxSpendableMsat int64 `json:"nextMaxSpendableMsat"` + NextMaxReceivable int64 `json:"nextMaxReceivable"` // deprecated + NextMaxReceivableSat int64 `json:"nextMaxReceivableSat"` + NextMaxReceivableMsat int64 `json:"nextMaxReceivableMsat"` + NextMaxSpendableMPP int64 `json:"nextMaxSpendableMPP"` // deprecated + NextMaxSpendableMPPSat int64 `json:"nextMaxSpendableMPPSat"` + NextMaxSpendableMPPMsat int64 `json:"nextMaxSpendableMPPMsat"` + NextMaxReceivableMPP int64 `json:"nextMaxReceivableMPP"` // deprecated + NextMaxReceivableMPPSat int64 `json:"nextMaxReceivableMPPSat"` + NextMaxReceivableMPPMsat int64 `json:"nextMaxReceivableMPPMsat"` +} + +type BalancesResponse struct { + Onchain OnchainBalanceResponse `json:"onchain"` + Lightning LightningBalanceResponse `json:"lightning"` +} type SendPaymentResponse = Transaction type MakeInvoiceResponse = Transaction type LookupInvoiceResponse = Transaction +type SetTransactionUserLabelsRequest struct { + Labels map[string]string `json:"labels"` +} + +type ListTransactionsFilters struct { + Type *string + MinAmountMsat *uint64 + HideFailed bool + SearchTerm string +} + type ListTransactionsResponse struct { TotalCount uint64 `json:"totalCount"` Transactions []Transaction `json:"transactions"` @@ -363,6 +514,7 @@ type ListTransactionsResponse struct { // TODO: camelCase type Transaction struct { + ID uint `json:"id"` Type string `json:"type"` State string `json:"state"` Invoice string `json:"invoice"` @@ -370,8 +522,12 @@ type Transaction struct { DescriptionHash string `json:"descriptionHash"` Preimage *string `json:"preimage"` PaymentHash string `json:"paymentHash"` - Amount uint64 `json:"amount"` - FeesPaid uint64 `json:"feesPaid"` + Amount uint64 `json:"amount"` // deprecated + AmountSat uint64 `json:"amountSat"` + AmountMsat uint64 `json:"amountMsat"` + FeesPaid uint64 `json:"feesPaid"` // deprecated + FeesPaidSat uint64 `json:"feesPaidSat"` + FeesPaidMsat uint64 `json:"feesPaidMsat"` UpdatedAt string `json:"updatedAt"` CreatedAt string `json:"createdAt"` SettledAt *string `json:"settledAt"` @@ -397,27 +553,10 @@ type Boostagram struct { SenderName string `json:"senderName"` Time string `json:"time"` Action string `json:"action"` + ValueSatTotal int64 `json:"valueSatTotal"` ValueMsatTotal int64 `json:"valueMsatTotal"` } -// debug api -type SendPaymentProbesRequest struct { - Invoice string `json:"invoice"` -} - -type SendPaymentProbesResponse struct { - Error string `json:"error"` -} - -type SendSpontaneousPaymentProbesRequest struct { - Amount uint64 `json:"amount"` - NodeId string `json:"nodeId"` -} - -type SendSpontaneousPaymentProbesResponse struct { - Error string `json:"error"` -} - const ( LogTypeNode = "node" LogTypeApp = "app" @@ -441,8 +580,11 @@ type SignMessageResponse struct { } type PayInvoiceRequest struct { - Amount *uint64 `json:"amount"` - Metadata Metadata `json:"metadata"` + Amount *uint64 `json:"amount"` // deprecated + AmountSat *uint64 `json:"amountSat"` + AmountMsat *uint64 `json:"amountMsat"` + Metadata Metadata `json:"metadata"` + FromAppID *uint `json:"fromAppId"` } type MakeOfferRequest struct { @@ -450,8 +592,11 @@ type MakeOfferRequest struct { } type MakeInvoiceRequest struct { - Amount uint64 `json:"amount"` - Description string `json:"description"` + Amount *uint64 `json:"amount"` // deprecated + AmountSat *uint64 `json:"amountSat"` + AmountMsat *uint64 `json:"amountMsat"` + Description string `json:"description"` + ToAppID *uint `json:"toAppId"` } type ResetRouterRequest struct { @@ -466,21 +611,26 @@ type BasicRestoreWailsRequest struct { UnlockPassword string `json:"unlockPassword"` } -type NetworkGraphResponse = lnclient.NetworkGraphResponse +type NetworkGraphResponse = interface{} type LSPOrderRequest struct { - Amount uint64 `json:"amount"` - LSPType string `json:"lspType"` - LSPIdentifier string `json:"lspIdentifier"` - Public bool `json:"public"` + Amount *uint64 `json:"amount"` // deprecated + AmountSat *uint64 `json:"amountSat"` + LSPType string `json:"lspType"` + LSPIdentifier string `json:"lspIdentifier"` + Public bool `json:"public"` } type LSPOrderResponse struct { - Invoice string `json:"invoice"` - Fee uint64 `json:"fee"` - InvoiceAmount uint64 `json:"invoiceAmount"` - IncomingLiquidity uint64 `json:"incomingLiquidity"` - OutgoingLiquidity uint64 `json:"outgoingLiquidity"` + Invoice string `json:"invoice"` + Fee uint64 `json:"fee"` // deprecated + FeeSat uint64 `json:"feeSat"` + InvoiceAmount uint64 `json:"invoiceAmount"` // deprecated + InvoiceAmountSat uint64 `json:"invoiceAmountSat"` + IncomingLiquidity uint64 `json:"incomingLiquidity"` // deprecated + IncomingLiquiditySat uint64 `json:"incomingLiquiditySat"` + OutgoingLiquidity uint64 `json:"outgoingLiquidity"` // deprecated + OutgoingLiquiditySat uint64 `json:"outgoingLiquiditySat"` } type WalletCapabilitiesResponse struct { @@ -490,25 +640,33 @@ type WalletCapabilitiesResponse struct { } type Channel struct { - LocalBalance int64 `json:"localBalance"` - LocalSpendableBalance int64 `json:"localSpendableBalance"` - RemoteBalance int64 `json:"remoteBalance"` - Id string `json:"id"` - RemotePubkey string `json:"remotePubkey"` - FundingTxId string `json:"fundingTxId"` - FundingTxVout uint32 `json:"fundingTxVout"` - Active bool `json:"active"` - Public bool `json:"public"` - InternalChannel interface{} `json:"internalChannel"` - Confirmations *uint32 `json:"confirmations"` - ConfirmationsRequired *uint32 `json:"confirmationsRequired"` - ForwardingFeeBaseMsat uint32 `json:"forwardingFeeBaseMsat"` - ForwardingFeeProportionalMillionths uint32 `json:"forwardingFeeProportionalMillionths"` - UnspendablePunishmentReserve uint64 `json:"unspendablePunishmentReserve"` - CounterpartyUnspendablePunishmentReserve uint64 `json:"counterpartyUnspendablePunishmentReserve"` - Error *string `json:"error"` - Status string `json:"status"` - IsOutbound bool `json:"isOutbound"` + LocalBalance int64 `json:"localBalance"` // deprecated + LocalBalanceSat int64 `json:"localBalanceSat"` + LocalBalanceMsat int64 `json:"localBalanceMsat"` + LocalSpendableBalance int64 `json:"localSpendableBalance"` // deprecated + LocalSpendableBalanceSat int64 `json:"localSpendableBalanceSat"` + LocalSpendableBalanceMsat int64 `json:"localSpendableBalanceMsat"` + RemoteBalance int64 `json:"remoteBalance"` // deprecated + RemoteBalanceSat int64 `json:"remoteBalanceSat"` + RemoteBalanceMsat int64 `json:"remoteBalanceMsat"` + Id string `json:"id"` + RemotePubkey string `json:"remotePubkey"` + FundingTxId string `json:"fundingTxId"` + FundingTxVout uint32 `json:"fundingTxVout"` + Active bool `json:"active"` + Public bool `json:"public"` + InternalChannel interface{} `json:"internalChannel"` + Confirmations *uint32 `json:"confirmations"` + ConfirmationsRequired *uint32 `json:"confirmationsRequired"` + ForwardingFeeBaseMsat uint32 `json:"forwardingFeeBaseMsat"` // expressed only in msat as per Lightning spec + ForwardingFeeProportionalMillionths uint32 `json:"forwardingFeeProportionalMillionths"` + UnspendablePunishmentReserve uint64 `json:"unspendablePunishmentReserve"` // deprecated + UnspendablePunishmentReserveSat uint64 `json:"unspendablePunishmentReserveSat"` + CounterpartyUnspendablePunishmentReserve uint64 `json:"counterpartyUnspendablePunishmentReserve"` // deprecated + CounterpartyUnspendablePunishmentReserveSat uint64 `json:"counterpartyUnspendablePunishmentReserveSat"` + Error *string `json:"error"` + Status string `json:"status"` + IsOutbound bool `json:"isOutbound"` } type MigrateNodeStorageRequest struct { @@ -561,7 +719,53 @@ type ExecuteCustomNodeCommandRequest struct { } type GetForwardsResponse struct { + OutboundAmountForwardedSat uint64 `json:"outboundAmountForwardedSat"` OutboundAmountForwardedMsat uint64 `json:"outboundAmountForwardedMsat"` + TotalFeeEarnedSat uint64 `json:"totalFeeEarnedSat"` TotalFeeEarnedMsat uint64 `json:"totalFeeEarnedMsat"` NumForwards uint64 `json:"numForwards"` } + +func ResolveToSat(satValue *uint64, msatValue *uint64, legacyValueSat *uint64, legacyValueMsat *uint64) (resolvedSatValue *uint64) { + if legacyValueSat != nil { + resolvedSatValue = legacyValueSat + } + + if legacyValueMsat != nil { + tmpSat := *legacyValueMsat / 1000 + resolvedSatValue = &tmpSat + } + + if satValue != nil { + resolvedSatValue = satValue + } + + if msatValue != nil { + tmpSat := *msatValue / 1000 + resolvedSatValue = &tmpSat + } + + return resolvedSatValue +} + +func ResolveToMsat(satValue *uint64, msatValue *uint64, legacyValueSat *uint64, legacyValueMsat *uint64) (resolvedMsatValue *uint64) { + if legacyValueSat != nil { + tmpMsat := *legacyValueSat * 1000 + resolvedMsatValue = &tmpMsat + } + + if legacyValueMsat != nil { + resolvedMsatValue = legacyValueMsat + } + + if satValue != nil { + tmpMsat := *satValue * 1000 + resolvedMsatValue = &tmpMsat + } + + if msatValue != nil { + resolvedMsatValue = msatValue + } + + return resolvedMsatValue +} diff --git a/api/rebalance.go b/api/rebalance.go index f81cc9d9..2854d5b3 100644 --- a/api/rebalance.go +++ b/api/rebalance.go @@ -18,15 +18,22 @@ import ( ) func (api *api) RebalanceChannel(ctx context.Context, rebalanceChannelRequest *RebalanceChannelRequest) (*RebalanceChannelResponse, error) { - if api.svc.GetLNClient() == nil { - return nil, errors.New("LNClient not started") + lnClient := api.svc.GetLNClient() + if lnClient == nil { + return nil, ErrLNClientNotStarted + } + + amountSat := uint64(0) + resolvedAmountSat := ResolveToSat(rebalanceChannelRequest.AmountSat, rebalanceChannelRequest.AmountMsat, nil, nil) + if resolvedAmountSat != nil { + amountSat = *resolvedAmountSat } receiveMetadata := map[string]interface{}{ "receive_through": rebalanceChannelRequest.ReceiveThroughNodePubkey, } - receiveInvoice, err := api.svc.GetTransactionsService().MakeInvoice(ctx, rebalanceChannelRequest.AmountSat*1000, "Alby Hub Rebalance through "+rebalanceChannelRequest.ReceiveThroughNodePubkey, "", 0, receiveMetadata, api.svc.GetLNClient(), nil, nil, &rebalanceChannelRequest.ReceiveThroughNodePubkey) + receiveInvoice, err := api.svc.GetTransactionsService().MakeInvoice(ctx, amountSat*1000, "Alby Hub Rebalance through "+rebalanceChannelRequest.ReceiveThroughNodePubkey, "", 0, receiveMetadata, lnClient, nil, nil, &rebalanceChannelRequest.ReceiveThroughNodePubkey) if err != nil { logger.Logger.WithError(err).Error("failed to generate rebalance receive invoice") return nil, err @@ -83,7 +90,7 @@ func (api *api) RebalanceChannel(ctx context.Context, rebalanceChannelRequest *R return nil, errors.New("failed to read response body") } - if res.StatusCode >= 300 { + if res.StatusCode != http.StatusOK { logger.Logger.WithFields(logrus.Fields{ "request": newRspCreateOrderRequest, "body": string(body), @@ -115,17 +122,17 @@ func (api *api) RebalanceChannel(ctx context.Context, rebalanceChannelRequest *R return nil, err } - if paymentRequest.MSatoshi > int64(float64(rebalanceChannelRequest.AmountSat)*float64(1000)*float64(1.003)+1 /*0.3% fees*/) { + if paymentRequest.MSatoshi > int64(float64(amountSat)*float64(1000)*float64(1.005)+1 /*0.5% fees*/) { return nil, errors.New("rebalance payment is more expensive than expected") } payMetadata := map[string]interface{}{ "receive_through": rebalanceChannelRequest.ReceiveThroughNodePubkey, - "amount_sat": rebalanceChannelRequest.AmountSat, + "amount_sat": amountSat, "order_id": rebalanceCreateOrderResponse.OrderId, } - payRebalanceInvoiceResponse, err := api.svc.GetTransactionsService().SendPaymentSync(rebalanceCreateOrderResponse.PayRequest, nil, payMetadata, api.svc.GetLNClient(), nil, nil) + payRebalanceInvoiceResponse, err := api.svc.GetTransactionsService().SendPaymentSync(rebalanceCreateOrderResponse.PayRequest, nil, payMetadata, lnClient, nil, nil) if err != nil { logger.Logger.WithError(err).Error("failed to pay rebalance invoice") @@ -137,7 +144,10 @@ func (api *api) RebalanceChannel(ctx context.Context, rebalanceChannelRequest *R Properties: map[string]interface{}{}, }) + totalFeeMsat := uint64(paymentRequest.MSatoshi) + payRebalanceInvoiceResponse.FeeMsat - amountSat*1000 + return &RebalanceChannelResponse{ - TotalFeeSat: uint64(paymentRequest.MSatoshi)/1000 + payRebalanceInvoiceResponse.FeeMsat/1000 - rebalanceChannelRequest.AmountSat, + TotalFeeSat: totalFeeMsat / 1000, + TotalFeeMsat: totalFeeMsat, }, nil } diff --git a/api/setup_test.go b/api/setup_test.go new file mode 100644 index 00000000..2ac03ed5 --- /dev/null +++ b/api/setup_test.go @@ -0,0 +1,161 @@ +package api + +import ( + "crypto/ecdsa" + "crypto/elliptic" + crand "crypto/rand" + "crypto/x509" + "crypto/x509/pkix" + "encoding/hex" + "encoding/pem" + "math/big" + "os" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/require" + "gopkg.in/macaroon.v2" +) + +// generateTestCert returns a self-signed certificate PEM block and its +// matching EC private key PEM block. +func generateTestCert(t *testing.T) (certPEM []byte, keyPEM []byte) { + t.Helper() + + key, err := ecdsa.GenerateKey(elliptic.P256(), crand.Reader) + require.NoError(t, err) + + template := x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{CommonName: "test"}, + NotBefore: time.Unix(0, 0), + NotAfter: time.Unix(1<<31, 0), + } + der, err := x509.CreateCertificate(crand.Reader, &template, &template, &key.PublicKey, key) + require.NoError(t, err) + + certPEM = pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}) + + keyDER, err := x509.MarshalPKCS8PrivateKey(key) + require.NoError(t, err) + keyPEM = pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: keyDER}) + + return certPEM, keyPEM +} + +func TestReadAndCanonicalizeLNDCert(t *testing.T) { + certPEM, keyPEM := generateTestCert(t) + dir := t.TempDir() + + t.Run("valid certificate", func(t *testing.T) { + path := filepath.Join(dir, "tls.cert") + require.NoError(t, os.WriteFile(path, certPEM, 0600)) + + got, err := readAndCanonicalizeLNDCert(path) + require.NoError(t, err) + + raw, err := hex.DecodeString(got) + require.NoError(t, err) + require.True(t, x509.NewCertPool().AppendCertsFromPEM(raw)) + }) + + t.Run("bundled private key is stripped", func(t *testing.T) { + path := filepath.Join(dir, "bundle.pem") + require.NoError(t, os.WriteFile(path, append(append([]byte{}, certPEM...), keyPEM...), 0600)) + + got, err := readAndCanonicalizeLNDCert(path) + require.NoError(t, err) + + raw, err := hex.DecodeString(got) + require.NoError(t, err) + // Only the CERTIFICATE block must survive - the private key must not + // be persisted. + require.NotContains(t, string(raw), "PRIVATE KEY") + require.Contains(t, string(raw), "CERTIFICATE") + }) + + t.Run("arbitrary non-cert file is rejected", func(t *testing.T) { + path := filepath.Join(dir, "secret.txt") + require.NoError(t, os.WriteFile(path, []byte("root:x:0:0:root:/root:/bin/bash\n"), 0600)) + + _, err := readAndCanonicalizeLNDCert(path) + require.Error(t, err) + }) + + t.Run("missing file is rejected", func(t *testing.T) { + _, err := readAndCanonicalizeLNDCert(filepath.Join(dir, "does-not-exist")) + require.Error(t, err) + }) +} + +func TestReadAndCanonicalizeLNDMacaroon(t *testing.T) { + dir := t.TempDir() + + t.Run("valid macaroon", func(t *testing.T) { + mac, err := macaroon.New([]byte("root-key"), []byte("id"), "location", macaroon.LatestVersion) + require.NoError(t, err) + raw, err := mac.MarshalBinary() + require.NoError(t, err) + + path := filepath.Join(dir, "admin.macaroon") + require.NoError(t, os.WriteFile(path, raw, 0600)) + + got, err := readAndCanonicalizeLNDMacaroon(path) + require.NoError(t, err) + + gotRaw, err := hex.DecodeString(got) + require.NoError(t, err) + roundTrip := &macaroon.Macaroon{} + require.NoError(t, roundTrip.UnmarshalBinary(gotRaw)) + }) + + t.Run("arbitrary non-macaroon file is rejected", func(t *testing.T) { + path := filepath.Join(dir, "id_rsa") + require.NoError(t, os.WriteFile(path, []byte("-----BEGIN OPENSSH PRIVATE KEY-----\nsecret\n"), 0600)) + + _, err := readAndCanonicalizeLNDMacaroon(path) + require.Error(t, err) + }) + + t.Run("missing file is rejected", func(t *testing.T) { + _, err := readAndCanonicalizeLNDMacaroon(filepath.Join(dir, "does-not-exist")) + require.Error(t, err) + }) +} + +func TestValidateCLNLightningDir(t *testing.T) { + certPEM, keyPEM := generateTestCert(t) + + writeCLNDir := func(t *testing.T, dir string) { + t.Helper() + require.NoError(t, os.WriteFile(filepath.Join(dir, "ca.pem"), certPEM, 0600)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "client.pem"), certPEM, 0600)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "client-key.pem"), keyPEM, 0600)) + } + + t.Run("valid directory", func(t *testing.T) { + dir := t.TempDir() + writeCLNDir(t, dir) + require.NoError(t, validateCLNLightningDir(dir, false)) + }) + + t.Run("valid directory with hold subdirectory", func(t *testing.T) { + dir := t.TempDir() + writeCLNDir(t, dir) + holdDir := filepath.Join(dir, "hold") + require.NoError(t, os.Mkdir(holdDir, 0700)) + writeCLNDir(t, holdDir) + require.NoError(t, validateCLNLightningDir(dir, true)) + }) + + t.Run("hold requested but subdirectory missing", func(t *testing.T) { + dir := t.TempDir() + writeCLNDir(t, dir) + require.Error(t, validateCLNLightningDir(dir, true)) + }) + + t.Run("arbitrary directory is rejected", func(t *testing.T) { + require.Error(t, validateCLNLightningDir(t.TempDir(), false)) + }) +} diff --git a/api/transactions.go b/api/transactions.go index 9bdc7c4a..eb5109b4 100644 --- a/api/transactions.go +++ b/api/transactions.go @@ -4,19 +4,29 @@ import ( "context" "encoding/json" "errors" + "fmt" + "net/url" + "strconv" "strings" "time" + "github.com/getAlby/hub/constants" "github.com/getAlby/hub/logger" "github.com/getAlby/hub/transactions" "github.com/sirupsen/logrus" ) -func (api *api) CreateInvoice(ctx context.Context, amount uint64, description string) (*MakeInvoiceResponse, error) { - if api.svc.GetLNClient() == nil { - return nil, errors.New("LNClient not started") +func (api *api) CreateInvoice(ctx context.Context, amountMsat uint64, description string, toAppId *uint) (*MakeInvoiceResponse, error) { + lnClient := api.svc.GetLNClient() + if lnClient == nil { + return nil, ErrLNClientNotStarted } - transaction, err := api.svc.GetTransactionsService().MakeInvoice(ctx, amount, description, "", 0, nil, api.svc.GetLNClient(), nil, nil, nil) + + if toAppId != nil && api.appsSvc.GetAppById(*toAppId) == nil { + return nil, errors.New("app does not exist") + } + + transaction, err := api.svc.GetTransactionsService().MakeInvoice(ctx, amountMsat, description, "", 0, nil, lnClient, toAppId, nil, nil) if err != nil { return nil, err } @@ -24,19 +34,65 @@ func (api *api) CreateInvoice(ctx context.Context, amount uint64, description st } func (api *api) LookupInvoice(ctx context.Context, paymentHash string) (*LookupInvoiceResponse, error) { - if api.svc.GetLNClient() == nil { - return nil, errors.New("LNClient not started") + lnClient := api.svc.GetLNClient() + if lnClient == nil { + return nil, ErrLNClientNotStarted } - transaction, err := api.svc.GetTransactionsService().LookupTransaction(ctx, paymentHash, nil, api.svc.GetLNClient(), nil) + transaction, err := api.svc.GetTransactionsService().LookupTransaction(ctx, paymentHash, nil, lnClient, nil) if err != nil { return nil, err } return toApiTransaction(transaction), nil } -func (api *api) ListTransactions(ctx context.Context, appId *uint, limit uint64, offset uint64) (*ListTransactionsResponse, error) { - if api.svc.GetLNClient() == nil { - return nil, errors.New("LNClient not started") +func (api *api) SetTransactionUserLabels(ctx context.Context, id uint, labels map[string]string) error { + return api.svc.GetTransactionsService().SetTransactionUserLabels(ctx, id, labels) +} + +// ParseListTransactionsFilters parses transaction filter query parameters +// shared by the HTTP and Wails transports. Invalid values return an error. +func ParseListTransactionsFilters(query url.Values) (ListTransactionsFilters, error) { + filters := ListTransactionsFilters{} + + if transactionType := query.Get("type"); transactionType != "" { + if transactionType != constants.TRANSACTION_TYPE_INCOMING && transactionType != constants.TRANSACTION_TYPE_OUTGOING { + return filters, fmt.Errorf("invalid type: %s", transactionType) + } + filters.Type = &transactionType + } + + if minAmountSatParam := query.Get("minAmountSat"); minAmountSatParam != "" { + minAmountSat, err := strconv.ParseUint(minAmountSatParam, 10, 64) + if err != nil || minAmountSat == 0 { + return filters, fmt.Errorf("invalid minAmountSat: %s", minAmountSatParam) + } + + const msatPerSat = uint64(1000) + if minAmountSat > ^uint64(0)/msatPerSat { + return filters, fmt.Errorf("minAmountSat is too large") + } + + minAmountMsat := minAmountSat * msatPerSat + filters.MinAmountMsat = &minAmountMsat + } + + if hideFailedParam := query.Get("hideFailed"); hideFailedParam != "" { + hideFailed, err := strconv.ParseBool(hideFailedParam) + if err != nil { + return filters, fmt.Errorf("invalid hideFailed: %s", hideFailedParam) + } + filters.HideFailed = hideFailed + } + + filters.SearchTerm = strings.TrimSpace(query.Get("search")) + + return filters, nil +} + +func (api *api) ListTransactions(ctx context.Context, appId *uint, limit uint64, offset uint64, filters ListTransactionsFilters) (*ListTransactionsResponse, error) { + lnClient := api.svc.GetLNClient() + if lnClient == nil { + return nil, ErrLNClientNotStarted } forceFilterByAppId := false @@ -44,13 +100,18 @@ func (api *api) ListTransactions(ctx context.Context, appId *uint, limit uint64, forceFilterByAppId = true } - transactions, totalCount, err := api.svc.GetTransactionsService().ListTransactions(ctx, 0, 0, limit, offset, true, false, nil, api.svc.GetLNClient(), appId, forceFilterByAppId) + dbTransactions, totalCount, err := api.svc.GetTransactionsService().ListTransactions(ctx, 0, 0, limit, offset, true, false, lnClient, appId, forceFilterByAppId, &transactions.ListTransactionsFilters{ + Type: filters.Type, + MinAmountMsat: filters.MinAmountMsat, + HideFailed: filters.HideFailed, + SearchTerm: filters.SearchTerm, + }) if err != nil { return nil, err } apiTransactions := []Transaction{} - for _, transaction := range transactions { + for _, transaction := range dbTransactions { apiTransactions = append(apiTransactions, *toApiTransaction(&transaction)) } @@ -60,11 +121,13 @@ func (api *api) ListTransactions(ctx context.Context, appId *uint, limit uint64, }, nil } -func (api *api) SendPayment(ctx context.Context, invoice string, amountMsat *uint64, metadata map[string]interface{}) (*SendPaymentResponse, error) { - if api.svc.GetLNClient() == nil { - return nil, errors.New("LNClient not started") +func (api *api) SendPayment(ctx context.Context, invoice string, amountMsat *uint64, metadata map[string]interface{}, appId *uint) (*SendPaymentResponse, error) { + lnClient := api.svc.GetLNClient() + if lnClient == nil { + return nil, ErrLNClientNotStarted } - transaction, err := api.svc.GetTransactionsService().SendPaymentSync(invoice, amountMsat, metadata, api.svc.GetLNClient(), nil, nil) + + transaction, err := api.svc.GetTransactionsService().SendPaymentSync(invoice, amountMsat, metadata, lnClient, appId, nil) if err != nil { return nil, err } @@ -108,6 +171,7 @@ func toApiTransaction(transaction *transactions.Transaction) *Transaction { } return &Transaction{ + ID: transaction.ID, Type: transaction.Type, State: strings.ToLower(transaction.State), Invoice: transaction.PaymentRequest, @@ -116,8 +180,12 @@ func toApiTransaction(transaction *transactions.Transaction) *Transaction { Preimage: preimage, PaymentHash: transaction.PaymentHash, Amount: transaction.AmountMsat, + AmountSat: transaction.AmountMsat / 1000, + AmountMsat: transaction.AmountMsat, AppId: transaction.AppId, FeesPaid: transaction.FeeMsat, + FeesPaidSat: transaction.FeeMsat / 1000, + FeesPaidMsat: transaction.FeeMsat, UpdatedAt: updatedAt, CreatedAt: createdAt, SettledAt: settledAt, @@ -127,9 +195,10 @@ func toApiTransaction(transaction *transactions.Transaction) *Transaction { } } -func (api *api) Transfer(ctx context.Context, fromAppId *uint, toAppId *uint, amountMsat uint64) error { - if api.svc.GetLNClient() == nil { - return errors.New("LNClient not started") +func (api *api) Transfer(ctx context.Context, fromAppId *uint, toAppId *uint, amountMsat uint64, description string) error { + lnClient := api.svc.GetLNClient() + if lnClient == nil { + return ErrLNClientNotStarted } for _, appId := range []*uint{fromAppId, toAppId} { @@ -144,13 +213,18 @@ func (api *api) Transfer(ctx context.Context, fromAppId *uint, toAppId *uint, am } } - transaction, err := api.svc.GetTransactionsService().MakeInvoice(ctx, amountMsat, "transfer", "", 0, nil, api.svc.GetLNClient(), toAppId, nil, nil) + // default to "transfer" + if description == "" { + description = "transfer" + } + + transaction, err := api.svc.GetTransactionsService().MakeInvoice(ctx, amountMsat, description, "", 0, nil, lnClient, toAppId, nil, nil) if err != nil { return err } - _, err = api.svc.GetTransactionsService().SendPaymentSync(transaction.PaymentRequest, nil, nil, api.svc.GetLNClient(), fromAppId, nil) + _, err = api.svc.GetTransactionsService().SendPaymentSync(transaction.PaymentRequest, nil, nil, lnClient, fromAppId, nil) return err } @@ -169,6 +243,7 @@ func toApiBoostagram(boostagram *transactions.Boostagram) *Boostagram { SenderName: boostagram.SenderName, Time: boostagram.Time, Action: boostagram.Action, + ValueSatTotal: boostagram.ValueMsatTotal / 1000, ValueMsatTotal: boostagram.ValueMsatTotal, } } diff --git a/api/transactions_test.go b/api/transactions_test.go new file mode 100644 index 00000000..934ef46f --- /dev/null +++ b/api/transactions_test.go @@ -0,0 +1,98 @@ +package api + +import ( + "context" + "net/url" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/getAlby/hub/tests" + "github.com/getAlby/hub/tests/mocks" + "github.com/getAlby/hub/transactions" +) + +func TestCreateInvoice_ToApp(t *testing.T) { + ctx := context.TODO() + + testSvc, err := tests.CreateTestService(t) + require.NoError(t, err) + defer testSvc.Remove() + + app, _, err := tests.CreateApp(testSvc) + require.NoError(t, err) + + svc := mocks.NewMockService(t) + svc.On("GetLNClient").Return(testSvc.LNClient) + svc.On("GetTransactionsService").Return(transactions.NewTransactionsService(testSvc.DB, testSvc.EventPublisher)) + + theAPI := &api{ + appsSvc: testSvc.AppsService, + svc: svc, + } + + transaction, err := theAPI.CreateInvoice(ctx, 1000, "Hello world", &app.ID) + + require.NoError(t, err) + require.NotNil(t, transaction.AppId) + assert.Equal(t, app.ID, *transaction.AppId) +} + +func TestCreateInvoice_ToAppNotFound(t *testing.T) { + ctx := context.TODO() + + testSvc, err := tests.CreateTestService(t) + require.NoError(t, err) + defer testSvc.Remove() + + svc := mocks.NewMockService(t) + svc.On("GetLNClient").Return(testSvc.LNClient) + + theAPI := &api{ + appsSvc: testSvc.AppsService, + svc: svc, + } + + missingAppId := uint(999) + transaction, err := theAPI.CreateInvoice(ctx, 1000, "Hello world", &missingAppId) + + assert.Nil(t, transaction) + require.Error(t, err) + assert.Equal(t, "app does not exist", err.Error()) +} + +func TestParseListTransactionsFilters(t *testing.T) { + minAmountMsat := uint64(1000_000) + outgoing := "outgoing" + + filters, err := ParseListTransactionsFilters(url.Values{ + "type": {"outgoing"}, + "minAmountSat": {"1000"}, + "hideFailed": {"true"}, + "search": {" coffee "}, + }) + require.NoError(t, err) + assert.Equal(t, ListTransactionsFilters{ + Type: &outgoing, + MinAmountMsat: &minAmountMsat, + HideFailed: true, + SearchTerm: "coffee", + }, filters) + + filters, err = ParseListTransactionsFilters(url.Values{}) + require.NoError(t, err) + assert.Equal(t, ListTransactionsFilters{}, filters) + + for _, invalidQuery := range []url.Values{ + {"type": {"sideways"}}, + {"minAmountSat": {"abc"}}, + {"minAmountSat": {"-1"}}, + {"minAmountSat": {"0"}}, + {"minAmountSat": {"18446744073709551615"}}, + {"hideFailed": {"maybe"}}, + } { + _, err = ParseListTransactionsFilters(invalidQuery) + assert.Error(t, err, "query: %v", invalidQuery) + } +} diff --git a/apps/apps_service.go b/apps/apps_service.go index 1adcddd9..62c53b98 100644 --- a/apps/apps_service.go +++ b/apps/apps_service.go @@ -9,13 +9,13 @@ import ( "strings" "time" + "github.com/getAlby/go-nostr" "github.com/getAlby/hub/config" "github.com/getAlby/hub/constants" "github.com/getAlby/hub/db" "github.com/getAlby/hub/events" "github.com/getAlby/hub/logger" "github.com/getAlby/hub/service/keys" - "github.com/nbd-wtf/go-nostr" "gorm.io/datatypes" "gorm.io/gorm" ) @@ -58,9 +58,11 @@ func (svc *appsService) CreateApp(name string, pubkey string, maxAmountSat uint6 backendType, _ := svc.cfg.Get("LNBackendType", "") if backendType != config.LDKBackendType && backendType != config.LNDBackendType && - backendType != config.PhoenixBackendType { + backendType != config.PhoenixBackendType && + backendType != config.BarkBackendType && + backendType != config.CLNBackendType { return nil, "", fmt.Errorf( - "sub-wallets are currently not supported on your node backend. Try LDK or LND") + "sub-wallets are currently not supported on your node backend. Try LDK, LND, PHOENIX, BARK, or CLN") } } @@ -184,11 +186,17 @@ func (svc *appsService) DeleteApp(app *db.App) error { if err != nil { return err } + walletPubkey := "" + if app.WalletPubkey != nil { + // only exists for non-legacy apps + walletPubkey = *app.WalletPubkey + } svc.eventPublisher.Publish(&events.Event{ Event: "nwc_app_deleted", Properties: map[string]interface{}{ - "name": app.Name, - "id": app.ID, + "name": app.Name, + "id": app.ID, + "walletPubkey": walletPubkey, }, }) return nil diff --git a/apps/tests/apps_service_test.go b/apps/tests/apps_service_test.go index ee4a413d..c4690fa0 100644 --- a/apps/tests/apps_service_test.go +++ b/apps/tests/apps_service_test.go @@ -54,5 +54,5 @@ func TestHandleCreateApp_IsolatedUnsupportedBackendType(t *testing.T) { assert.Nil(t, app) assert.Equal(t, "", secretKey) require.Error(t, err) - assert.Equal(t, "sub-wallets are currently not supported on your node backend. Try LDK or LND", err.Error()) + assert.Equal(t, "sub-wallets are currently not supported on your node backend. Try LDK, LND, PHOENIX, BARK, or CLN", err.Error()) } diff --git a/cmd/db_migrate/main.go b/cmd/db_migrate/main.go index c10f4a3e..4207a4c6 100644 --- a/cmd/db_migrate/main.go +++ b/cmd/db_migrate/main.go @@ -2,9 +2,7 @@ package main import ( "flag" - "fmt" "os" - "slices" "strconv" "github.com/sirupsen/logrus" @@ -14,18 +12,6 @@ import ( "github.com/getAlby/hub/logger" ) -var expectedTables = []string{ - "apps", - "app_permissions", - "request_events", - "response_events", - "transactions", - "swaps", - "user_configs", - "migrations", - "forwards", -} - func main() { var fromDSN, toDSN string @@ -64,54 +50,29 @@ func main() { } defer stopDB(toDB) - // Migrations are applied to both the source and the target DB, so - // schemas should be equal at this point. - err = checkSchema(fromDB) - if err != nil { - logger.Logger.WithError(err).Error("database schema check failed; the migration tool may be outdated") - os.Exit(1) - } - - // Check if VSS is enabled in the source database - var vssConfig db.UserConfig - result := fromDB.Where("key = ?", "LdkVssEnabled").First(&vssConfig) - if result.Error != nil { - if result.Error == gorm.ErrRecordNotFound { - logger.Logger.Error("LdkVssEnabled config not found in source DB. Migration will not proceed.") - } else { - logger.Logger.WithError(result.Error).Error("failed to query LdkVssEnabled config from source DB") + // When migrating to Postgres (e.g. a cloud deployment) the node data must + // be stored in VSS, since only the database is migrated by this tool. + if toDB.Dialector.Name() == "postgres" { + var vssConfig db.UserConfig + result := fromDB.Where("key = ?", "LdkVssEnabled").First(&vssConfig) + if result.Error != nil { + if result.Error == gorm.ErrRecordNotFound { + logger.Logger.Error("LdkVssEnabled config not found in source DB. Migration will not proceed.") + } else { + logger.Logger.WithError(result.Error).Error("failed to query LdkVssEnabled config from source DB") + } + os.Exit(1) } - os.Exit(1) - } - if vssConfig.Value != "true" { - logger.Logger.Error("VSS is not enabled in the source DB (LdkVssEnabled is not 'true'). Migration will not proceed.") - os.Exit(1) - } - logger.Logger.Info("LdkVssEnabled check passed.") - - // NOTE: we assume that excess request events have already been cleaned up due to the background task - // and only a maximum of ~1000 remain. - logger.Logger.Info("Deleting orphaned request events.") - err = fromDB.Exec("DELETE FROM request_events WHERE app_id NOT IN (SELECT id FROM apps);").Error - - if err != nil { - logger.Logger.WithError(err).Error("failed to delete orphaned request events") - os.Exit(1) - } - - // NOTE: we assume that excess response events have already been cleaned up due to the background task - // and only a maximum of ~1000 remain. - logger.Logger.Info("Deleting orphaned response events.") - err = fromDB.Exec("DELETE FROM response_events WHERE request_id NOT IN (SELECT id FROM request_events);").Error - - if err != nil { - logger.Logger.WithError(err).Error("failed to delete orphaned response events") - os.Exit(1) + if vssConfig.Value != "true" { + logger.Logger.Error("VSS is not enabled in the source DB (LdkVssEnabled is not 'true'). Migration will not proceed.") + os.Exit(1) + } + logger.Logger.Info("LdkVssEnabled check passed.") } logger.Logger.Info("migrating...") - err = migrateDB(fromDB, toDB) + err = db.MigrateDB(fromDB, toDB) if err != nil { logger.Logger.WithError(err).Error("failed to migrate database") os.Exit(1) @@ -119,175 +80,3 @@ func main() { logger.Logger.Info("migration complete") } - -func migrateDB(from, to *gorm.DB) error { - tx := to.Begin() - defer tx.Rollback() - - if err := tx.Error; err != nil { - return fmt.Errorf("failed to start transaction: %w", err) - } - - // Table migration order matters: referenced tables must be migrated - // before referencing tables. - - logger.Logger.Info("migrating apps...") - if err := migrateTable[db.App](from, tx); err != nil { - return fmt.Errorf("failed to migrate apps: %w", err) - } - - logger.Logger.Info("migrating app_permissions...") - if err := migrateTable[db.AppPermission](from, tx); err != nil { - return fmt.Errorf("failed to migrate app_permissions: %w", err) - } - - logger.Logger.Info("migrating request_events...") - if err := migrateTable[db.RequestEvent](from, tx); err != nil { - return fmt.Errorf("failed to migrate request_events: %w", err) - } - - logger.Logger.Info("migrating response_events...") - if err := migrateTable[db.ResponseEvent](from, tx); err != nil { - return fmt.Errorf("failed to migrate response_events: %w", err) - } - - logger.Logger.Info("migrating transactions...") - if err := migrateTable[db.Transaction](from, tx); err != nil { - return fmt.Errorf("failed to migrate transactions: %w", err) - } - - logger.Logger.Info("migrating user_configs...") - if err := migrateTable[db.UserConfig](from, tx); err != nil { - return fmt.Errorf("failed to migrate user_configs: %w", err) - } - - if to.Dialector.Name() == "postgres" { - logger.Logger.Info("resetting sequences...") - if err := resetSequences(tx); err != nil { - return fmt.Errorf("failed to reset sequences: %w", err) - } - } - - tx.Commit() - if err := tx.Error; err != nil { - return fmt.Errorf("failed to commit transaction: %w", err) - } - - return nil -} - -func migrateTable[T any](from, to *gorm.DB) error { - var data []T - if err := from.Find(&data).Error; err != nil { - return fmt.Errorf("failed to fetch data: %w", err) - } - - if len(data) == 0 { - return nil - } - - // to avoid "failed to migrate transactions: failed to insert data: extended protocol limited to 65535 parameters" - // see https://stackoverflow.com/questions/77372430/extended-protocol-limited-to-65535-parameters-golang-gorm - // max statements is 65535 - // but it's the number of records * columns - // to be safe, using a lower value of 1000. - // this will fail if any table has more than 65 columns, which I doubt we will have - max := 1000 - for i := 0; i < len(data); i += max { - j := min(i+max, len(data)) - - if err := to.Create(data[i:j]).Error; err != nil { - return fmt.Errorf("failed to insert data: %w", err) - } - } - - return nil -} - -func checkSchema(db *gorm.DB) error { - tables, err := listTables(db) - if err != nil { - return fmt.Errorf("failed to list database tables: %w", err) - } - - for _, table := range expectedTables { - if !slices.Contains(tables, table) { - return fmt.Errorf("table missing from the database: %q", table) - } - } - - for _, table := range tables { - if !slices.Contains(expectedTables, table) { - return fmt.Errorf("unexpected table found in the database: %q", table) - } - } - - return nil -} - -func listTables(db *gorm.DB) ([]string, error) { - var query string - - switch db.Dialector.Name() { - case "sqlite": - query = "SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%';" - case "postgres": - query = "SELECT tablename FROM pg_tables WHERE schemaname = 'public';" - default: - return nil, fmt.Errorf("unsupported database: %q", db.Dialector.Name()) - } - - rows, err := db.Raw(query).Rows() - if err != nil { - return nil, fmt.Errorf("failed to query table names: %w", err) - } - defer func() { - if err := rows.Close(); err != nil { - logger.Logger.WithError(err).Error("failed to close rows") - } - }() - - var tables []string - for rows.Next() { - var table string - if err := rows.Scan(&table); err != nil { - return nil, fmt.Errorf("failed to scan table name: %w", err) - } - tables = append(tables, table) - } - - return tables, nil -} - -func resetSequences(db *gorm.DB) error { - type resetReq struct { - table string - seq string - } - - resetReqs := []resetReq{ - {"apps", "apps_2_id_seq"}, - {"app_permissions", "app_permissions_2_id_seq"}, - {"request_events", "request_events_id_seq"}, - {"response_events", "response_events_id_seq"}, - {"transactions", "transactions_id_seq"}, - {"user_configs", "user_configs_id_seq"}, - } - - for _, req := range resetReqs { - if err := resetPostgresSequence(db, req.table, req.seq); err != nil { - return fmt.Errorf("failed to reset sequence %q for %q: %w", req.seq, req.table, err) - } - } - - return nil -} - -func resetPostgresSequence(db *gorm.DB, table string, seq string) error { - query := fmt.Sprintf("SELECT setval('%s', (SELECT MAX(id) FROM %s));", seq, table) - if err := db.Exec(query).Error; err != nil { - return fmt.Errorf("failed to execute setval(): %w", err) - } - - return nil -} diff --git a/cmd/db_migrate/migrate_test.go b/cmd/db_migrate/migrate_test.go index 240751cb..12ae54e1 100644 --- a/cmd/db_migrate/migrate_test.go +++ b/cmd/db_migrate/migrate_test.go @@ -30,40 +30,6 @@ func (e *testEnvironment) cleanup(t *testing.T) { require.NoError(t, err) } -func TestSchemaCheck(t *testing.T) { - type testCase struct { - name string - uri string - } - - tc := []testCase{ - { - name: "schema check sqlite", - uri: getTestSqliteURI(0), - }, - } - - if pgUri := getTestPostgresURI(); pgUri != "" { - tc = append(tc, testCase{ - name: "schema check postgres", - uri: pgUri, - }) - } - - logger.Init(strconv.Itoa(int(logrus.DebugLevel))) - - for _, tt := range tc { - t.Run(tt.name, func(t *testing.T) { - dbConn, err := test_db.NewDBWithURI(t, tt.uri) - require.NoError(t, err) - defer db.Stop(dbConn) - - err = checkSchema(dbConn) - require.NoError(t, err) - }) - } -} - func TestMigrate(t *testing.T) { type testCase struct { name string @@ -104,8 +70,17 @@ func TestMigrate(t *testing.T) { require.NoError(t, err) defer env.cleanup(t) - err = migrateDB(env.source, env.dest) + err = db.MigrateDB(env.source, env.dest) require.NoError(t, err) + + requireCount[db.App](t, env.dest, 2) + requireCount[db.AppPermission](t, env.dest, 2) + requireCount[db.RequestEvent](t, env.dest, 1) + requireCount[db.ResponseEvent](t, env.dest, 1) + requireCount[db.Transaction](t, env.dest, 1) + requireCount[db.Swap](t, env.dest, 1) + requireCount[db.Forward](t, env.dest, 1) + requireCount[db.UserConfig](t, env.dest, 1) }) } } @@ -148,7 +123,7 @@ func insertMockData(t *testing.T, tx *gorm.DB) { userCfg1 := &db.UserConfig{ Key: "Relay", - Value: "wss://relay.getalby.com/v1", + Value: "wss://relay.getalby.com", Encrypted: false, CreatedAt: baseTime, UpdatedAt: baseTime, @@ -200,6 +175,83 @@ func insertMockData(t *testing.T, tx *gorm.DB) { UpdatedAt: baseTime, } create(t, tx, app2Perm) + + requestEvent1 := &db.RequestEvent{ + AppId: &app1.ID, + NostrId: "a35a1ca6d1a06e08a509f2c8fe3edb2ba10811d030e2f6f3239e9f21203ac954", + ContentData: "{}", + Method: "pay_invoice", + State: "executed", + CreatedAt: baseTime, + UpdatedAt: baseTime, + } + create(t, tx, requestEvent1) + + responseEvent1 := &db.ResponseEvent{ + NostrId: "e30d55d0e4f0d5391a1a1379f1d8b7d38ad02b3554b06ca993aa8790a3153f61", + RequestId: requestEvent1.ID, + State: "confirmed", + RepliedAt: baseTime, + CreatedAt: baseTime, + UpdatedAt: baseTime, + } + create(t, tx, responseEvent1) + + transaction1 := &db.Transaction{ + AppId: &app1.ID, + RequestEventId: &requestEvent1.ID, + Type: "outgoing", + State: "settled", + AmountMsat: 21000, + FeeMsat: 1000, + PaymentRequest: "lnbc210n1invoice", + PaymentHash: "13d9764a54269fa4d5f4e7c410f4ffdbc839bbeaa2fcbb96343ca502f0c86e34", + Description: "test transaction", + Preimage: ptr("2c1ee1b464b1a1a147debe0ac0c8ce4b615f9bfa64d12a25c1c4d10ea45a5b02"), + CreatedAt: baseTime, + UpdatedAt: baseTime, + SettledAt: &baseTime, + Metadata: datatypes.JSON("{}"), + Boostagram: datatypes.JSON("{}"), + } + create(t, tx, transaction1) + + swap1 := &db.Swap{ + SwapId: "swap1", + Type: "out", + State: "success", + Invoice: "lnbc210n1swapinvoice", + SendAmountSat: 21000, + ReceiveAmountSat: 20000, + Preimage: "35a3f1a7a06a41b9ba3a1b1a8ff852e5085b3b593f8ba4677a35a1ca6d1a06e0", + PaymentHash: "e6b1a1379f1d8b7d38ad02b3554b06ca993aa8790a3153f61e30d55d0e4f0d53", + DestinationAddress: "bc1qtest", + LockupAddress: "bc1qlockup", + LockupTxId: "lockuptx", + ClaimTxId: "claimtx", + AutoSwap: false, + TimeoutBlockHeight: 900000, + BoltzPubkey: "02d1a06e08a509f2c8fe3edb2ba10811d030e2f6f3239e9f21203ac954a35a1c", + SwapTree: datatypes.JSON("{}"), + CreatedAt: baseTime, + UpdatedAt: baseTime, + } + create(t, tx, swap1) + + forward1 := &db.Forward{ + OutboundAmountForwardedMsat: 1000000, + TotalFeeEarnedMsat: 1000, + CreatedAt: baseTime, + UpdatedAt: baseTime, + } + create(t, tx, forward1) +} + +func requireCount[T any](t *testing.T, tx *gorm.DB, expected int64) { + var count int64 + var model T + require.NoError(t, tx.Model(&model).Count(&count).Error) + require.Equal(t, expected, count) } func create[T any](t *testing.T, tx *gorm.DB, v T) *gorm.DB { diff --git a/config/config.go b/config/config.go index 854fe63e..c0434665 100644 --- a/config/config.go +++ b/config/config.go @@ -7,7 +7,6 @@ import ( "errors" "fmt" "os" - "path" "strings" "sync" @@ -20,11 +19,12 @@ import ( ) type config struct { - Env *AppConfig - db *gorm.DB - cache map[string]map[string]string // key -> encryptionKeyHash -> value - cacheMutex sync.Mutex - jwtSecret string + Env *AppConfig + db *gorm.DB + cache map[string]map[string]string // key -> encryptionKeyHash -> value + cacheMutex sync.Mutex + jwtSecret string + jwtSecretMutex sync.Mutex } const ( @@ -112,36 +112,66 @@ func (cfg *config) init(env *AppConfig) error { } } + // CLN specific to support env variables + if cfg.Env.CLNAddress != "" { + err := cfg.SetUpdate("CLNAddress", cfg.Env.CLNAddress, "") + if err != nil { + return err + } + } + if cfg.Env.CLNLightningDir != "" { + err := cfg.SetUpdate("CLNLightningDir", cfg.Env.CLNLightningDir, "") + if err != nil { + return err + } + } + if cfg.Env.CLNAddressHold != "" { + err := cfg.SetUpdate("CLNAddressHold", cfg.Env.CLNAddressHold, "") + if err != nil { + return err + } + } + return nil } -func (cfg *config) SetupCompleted() bool { - // TODO: remove hasLdkDir check after 2025/01/01 - // to give time for users to update to 1.6.0+ - nodeLastStartTime, _ := cfg.Get("NodeLastStartTime", "") - ldkDir, err := os.Stat(path.Join(cfg.GetEnv().Workdir, "ldk")) - hasLdkDir := err == nil && ldkDir != nil && ldkDir.IsDir() +func (cfg *config) SetupCompleted() (bool, error) { + nodeLastStartTime, err := cfg.Get("NodeLastStartTime", "") + if err != nil { + return false, err + } logger.Logger.WithFields(logrus.Fields{ - "has_ldk_dir": hasLdkDir, "has_node_last_start_time": nodeLastStartTime != "", }).Debug("Checking if setup is completed") - return nodeLastStartTime != "" || hasLdkDir + return nodeLastStartTime != "", nil } func (cfg *config) GetJWTSecret() (string, error) { - if cfg.jwtSecret == "" { + cfg.jwtSecretMutex.Lock() + jwtSecret := cfg.jwtSecret + cfg.jwtSecretMutex.Unlock() + + if jwtSecret == "" { return "", errors.New("config not unlocked") } - return cfg.jwtSecret, nil + return jwtSecret, nil } -func (cfg *config) Unlock(encryptionKey string) error { +// Decrypt and store the JWT secret in memory +func (cfg *config) LoadJWTSecret(encryptionKey string) error { if !cfg.CheckUnlockPassword(encryptionKey) { return errors.New("incorrect password") } + cfg.jwtSecretMutex.Lock() + if cfg.jwtSecret != "" { + cfg.jwtSecretMutex.Unlock() + return nil + } + cfg.jwtSecretMutex.Unlock() + // TODO: remove encryptedJwtSecret check after 2027-01-01 // - all hubs should have updated to use an encrypted JWT secret by then encryptedJwtSecret, err := cfg.Get("JWTSecret", "") @@ -168,7 +198,9 @@ func (cfg *config) Unlock(encryptionKey string) error { return err } } + cfg.jwtSecretMutex.Lock() cfg.jwtSecret = jwtSecret + cfg.jwtSecretMutex.Unlock() return nil } @@ -353,7 +385,9 @@ func (cfg *config) ChangeUnlockPassword(currentUnlockPassword string, newUnlockP } // JWT secret will be set on config unlock (required after password change) + cfg.jwtSecretMutex.Lock() cfg.jwtSecret = "" + cfg.jwtSecretMutex.Unlock() return nil } @@ -374,7 +408,18 @@ func (cfg *config) SetAutoUnlockPassword(unlockPassword string) error { func (cfg *config) CheckUnlockPassword(encryptionKey string) bool { decryptedValue, err := cfg.Get("UnlockPasswordCheck", encryptionKey) - return err == nil && (decryptedValue == "" || decryptedValue == unlockPasswordCheck) + // require a non-empty match so an absent or empty canary always fails + return err == nil && decryptedValue != "" && decryptedValue == unlockPasswordCheck +} + +func (cfg *config) IsUnlockPasswordCheckSet() (bool, error) { + // Read the raw value with an empty encryption key so we can detect the + // presence of the canary row without needing the (possibly wrong) password. + value, err := cfg.Get("UnlockPasswordCheck", "") + if err != nil { + return false, fmt.Errorf("read unlock password check: %w", err) + } + return value != "", nil } func (cfg *config) SaveUnlockPasswordCheck(encryptionKey string) error { diff --git a/config/config_test.go b/config/config_test.go index 302c120c..1849e45a 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -56,8 +56,6 @@ func TestCheckUnlockPasswordCache(t *testing.T) { Workdir: ".test", }, db) require.NoError(t, err) - err = cfg.ChangeUnlockPassword("", unlockPassword) - require.NoError(t, err) err = cfg.SaveUnlockPasswordCheck(unlockPassword) require.NoError(t, err) diff --git a/config/models.go b/config/models.go index d890a4c0..48860104 100644 --- a/config/models.go +++ b/config/models.go @@ -5,6 +5,8 @@ const ( LDKBackendType = "LDK" PhoenixBackendType = "PHOENIX" CashuBackendType = "CASHU" + CLNBackendType = "CLN" + BarkBackendType = "BARK" ) const ( @@ -16,7 +18,7 @@ const ( ) type AppConfig struct { - Relay string `envconfig:"RELAY" default:"wss://relay.getalby.com/v1"` + Relay string `envconfig:"RELAY" default:"wss://relay.getalby.com,wss://relay2.getalby.com"` LNBackendType string `envconfig:"LN_BACKEND_TYPE"` LNDAddress string `envconfig:"LND_ADDRESS"` LNDCertFile string `envconfig:"LND_CERT_FILE"` @@ -34,7 +36,9 @@ type AppConfig struct { LDKLogLevel string `envconfig:"LDK_LOG_LEVEL" default:"3"` LDKMaxChannelSaturationPowerOfHalf uint8 `envconfig:"LDK_MAX_CHANNEL_SATURATION" default:"2"` LDKMaxPathCount uint8 `envconfig:"LDK_MAX_PATH_COUNT" default:"5"` + LDKChannelMonitorWarningSizeBytes uint64 `envconfig:"LDK_CHANNEL_MONITOR_WARNING_SIZE_BYTES" default:"5000000"` LDKVssUrl string `envconfig:"LDK_VSS_URL" default:"https://vss.getalbypro.com/vss"` + LDKLiquiditySourceLsps2 string `envconfig:"LDK_LSPS2_ADDRESSES"` LDKListeningAddresses string `envconfig:"LDK_LISTENING_ADDRESSES" default:"[::]:9735"` LDKAnnouncementAddresses string `envconfig:"LDK_ANNOUNCEMENT_ADDRESSES"` LDKTransientNetworkGraph bool `envconfig:"LDK_TRANSIENT_NETWORK_GRAPH" default:"false"` @@ -57,6 +61,14 @@ type AppConfig struct { AutoUnlockPassword string `envconfig:"AUTO_UNLOCK_PASSWORD"` LogDBQueries bool `envconfig:"LOG_DB_QUERIES" default:"false"` BoltzApi string `envconfig:"BOLTZ_API" default:"https://api.boltz.exchange"` + HideUpdateBanner bool `envconfig:"HIDE_UPDATE_BANNER" default:"false"` + CLNAddress string `envconfig:"CLN_ADDRESS"` + CLNLightningDir string `envconfig:"CLN_LIGHTNING_DIR"` + CLNAddressHold string `envconfig:"CLN_ADDRESS_HOLD"` + BarkServer string `envconfig:"BARK_SERVER" default:"https://ark.second.tech"` + BarkEsploraServer string `envconfig:"BARK_ESPLORA_SERVER" default:"https://mempool.second.tech/api"` + BarkServerAccessToken string `envconfig:"BARK_SERVER_ACCESS_TOKEN"` + BarkLogLevel string `envconfig:"BARK_LOG_LEVEL" default:"3"` } func (c *AppConfig) IsDefaultClientId() bool { @@ -72,20 +84,21 @@ func (c *AppConfig) GetBaseFrontendUrl() string { } type Config interface { - Unlock(encryptionKey string) error Get(key string, encryptionKey string) (string, error) SetIgnore(key string, value string, encryptionKey string) error SetUpdate(key string, value string, encryptionKey string) error + LoadJWTSecret(encryptionKey string) error GetJWTSecret() (string, error) GetRelayUrls() []string GetNetwork() string GetMempoolUrl() string GetEnv() *AppConfig CheckUnlockPassword(password string) bool + IsUnlockPasswordCheckSet() (bool, error) ChangeUnlockPassword(currentUnlockPassword string, newUnlockPassword string) error SetAutoUnlockPassword(unlockPassword string) error SaveUnlockPasswordCheck(encryptionKey string) error - SetupCompleted() bool + SetupCompleted() (bool, error) GetCurrency() string SetCurrency(value string) error GetBitcoinDisplayFormat() string diff --git a/config/tests/config_test.go b/config/tests/config_test.go index 842943bc..c256f542 100644 --- a/config/tests/config_test.go +++ b/config/tests/config_test.go @@ -16,8 +16,6 @@ func TestCheckUnlockPasswordCache_InvalidSecond(t *testing.T) { require.NoError(t, err) defer svc.Remove() - err = svc.Cfg.ChangeUnlockPassword("", unlockPassword) - require.NoError(t, err) err = svc.Cfg.SaveUnlockPasswordCheck(unlockPassword) require.NoError(t, err) @@ -35,8 +33,6 @@ func TestCheckUnlockPasswordCache_InvalidFirst(t *testing.T) { require.NoError(t, err) defer svc.Remove() - err = svc.Cfg.ChangeUnlockPassword("", unlockPassword) - require.NoError(t, err) err = svc.Cfg.SaveUnlockPasswordCheck(unlockPassword) require.NoError(t, err) @@ -58,8 +54,6 @@ func TestCheckUnlockPassword_ChangePassword(t *testing.T) { require.NoError(t, err) defer svc.Remove() - err = svc.Cfg.ChangeUnlockPassword("", unlockPassword) - require.NoError(t, err) err = svc.Cfg.SaveUnlockPasswordCheck(unlockPassword) require.NoError(t, err) @@ -81,6 +75,50 @@ func TestCheckUnlockPassword_ChangePassword(t *testing.T) { assert.True(t, svc.Cfg.CheckUnlockPassword(newUnlockPassword)) } +func TestCheckUnlockPassword_MissingCanaryFailsClosed(t *testing.T) { + svc, err := tests.CreateTestService(t) + require.NoError(t, err) + defer svc.Remove() + + // A fresh hub has not saved the unlock-password canary yet. + set, err := svc.Cfg.IsUnlockPasswordCheckSet() + require.NoError(t, err) + assert.False(t, set) + + // Without the canary, no password may validate - including an empty one. + assert.False(t, svc.Cfg.CheckUnlockPassword("")) + assert.False(t, svc.Cfg.CheckUnlockPassword("any-password")) + + // After the canary is saved, only the correct password validates. + err = svc.Cfg.SaveUnlockPasswordCheck("correct") + require.NoError(t, err) + + set, err = svc.Cfg.IsUnlockPasswordCheckSet() + require.NoError(t, err) + assert.True(t, set) + + assert.True(t, svc.Cfg.CheckUnlockPassword("correct")) + assert.False(t, svc.Cfg.CheckUnlockPassword("wrong")) + assert.False(t, svc.Cfg.CheckUnlockPassword("")) +} + +func TestCheckUnlockPassword_NoPasswordHub(t *testing.T) { + svc, err := tests.CreateTestService(t) + require.NoError(t, err) + defer svc.Remove() + + // A hub configured without an unlock password stores the canary unencrypted; + // the empty password must still validate after the fail-closed change. + err = svc.Cfg.SaveUnlockPasswordCheck("") + require.NoError(t, err) + + set, err := svc.Cfg.IsUnlockPasswordCheckSet() + require.NoError(t, err) + assert.True(t, set) + + assert.True(t, svc.Cfg.CheckUnlockPassword("")) +} + func TestSetIgnore_NoEncryptionKey(t *testing.T) { svc, err := tests.CreateTestService(t) require.NoError(t, err) @@ -211,7 +249,7 @@ func TestSetUpdate_EncryptionKeyToNoEncryptionKey(t *testing.T) { assert.Equal(t, "value2", updatedValue) } -func TestJWTSecret_GeneratedOnUnlock(t *testing.T) { +func TestJWTSecret_GeneratedOnLoad(t *testing.T) { svc, err := tests.CreateTestService(t) require.NoError(t, err) defer svc.Remove() @@ -219,10 +257,10 @@ func TestJWTSecret_GeneratedOnUnlock(t *testing.T) { cfg, err := config.NewConfig(&config.AppConfig{}, svc.DB) require.NoError(t, err) - err = cfg.ChangeUnlockPassword("", "123") + err = cfg.SaveUnlockPasswordCheck("123") require.NoError(t, err) - err = cfg.Unlock("123") + err = cfg.LoadJWTSecret("123") require.NoError(t, err) jwtSecret, err := cfg.GetJWTSecret() @@ -235,14 +273,32 @@ func TestJWTSecret_GeneratedOnUnlock(t *testing.T) { require.NoError(t, err) assert.NotEqual(t, encryptedSecret, decryptedSecret) - // unlock again without doing anything, ensure the same JWT secret is returned - err = cfg.Unlock("123") + // load again without doing anything, ensure the same JWT secret is returned + err = cfg.LoadJWTSecret("123") require.NoError(t, err) jwtSecret2, err := cfg.GetJWTSecret() require.NoError(t, err) assert.Equal(t, jwtSecret, jwtSecret2) } +func TestJWTSecret_WrongPassword(t *testing.T) { + svc, err := tests.CreateTestService(t) + require.NoError(t, err) + defer svc.Remove() + + cfg, err := config.NewConfig(&config.AppConfig{}, svc.DB) + require.NoError(t, err) + + err = cfg.SaveUnlockPasswordCheck("123") + require.NoError(t, err) + + err = cfg.LoadJWTSecret("123") + require.NoError(t, err) + + err = cfg.LoadJWTSecret("wrong") + require.ErrorContains(t, err, "incorrect password") +} + func TestJWTSecret_ChangePassword(t *testing.T) { svc, err := tests.CreateTestService(t) require.NoError(t, err) @@ -251,23 +307,23 @@ func TestJWTSecret_ChangePassword(t *testing.T) { cfg, err := config.NewConfig(&config.AppConfig{}, svc.DB) require.NoError(t, err) - err = cfg.ChangeUnlockPassword("", "123") + err = cfg.SaveUnlockPasswordCheck("123") require.NoError(t, err) - err = cfg.Unlock("123") + err = cfg.LoadJWTSecret("123") require.NoError(t, err) jwtSecret, err := cfg.GetJWTSecret() require.NoError(t, err) assert.NotEmpty(t, jwtSecret) - err = cfg.ChangeUnlockPassword("", "1234") + err = cfg.ChangeUnlockPassword("123", "1234") require.NoError(t, err) newJwtSecret, err := cfg.GetJWTSecret() require.ErrorContains(t, err, "unlock") - err = cfg.Unlock("1234") + err = cfg.LoadJWTSecret("1234") require.NoError(t, err) // a new JWT secret must be generated after password change @@ -277,7 +333,7 @@ func TestJWTSecret_ChangePassword(t *testing.T) { assert.NotEqual(t, newJwtSecret, jwtSecret) } -func TestJWTSecret_ReplaceUnencryptedSecretOnUnlock(t *testing.T) { +func TestJWTSecret_ReplaceUnencryptedSecretOnLoad(t *testing.T) { svc, err := tests.CreateTestService(t) require.NoError(t, err) defer svc.Remove() @@ -285,7 +341,7 @@ func TestJWTSecret_ReplaceUnencryptedSecretOnUnlock(t *testing.T) { cfg, err := config.NewConfig(&config.AppConfig{}, svc.DB) require.NoError(t, err) - err = cfg.ChangeUnlockPassword("", "123") + err = cfg.SaveUnlockPasswordCheck("123") require.NoError(t, err) // simulate a hub that had an unencrypted JWT secret @@ -293,7 +349,7 @@ func TestJWTSecret_ReplaceUnencryptedSecretOnUnlock(t *testing.T) { err = svc.Cfg.SetUpdate("JWTSecret", oldJwtSecret, "") require.NoError(t, err) - err = cfg.Unlock("123") + err = cfg.LoadJWTSecret("123") require.NoError(t, err) jwtSecret, err := cfg.GetJWTSecret() diff --git a/constants/constants.go b/constants/constants.go index f24553d2..97c941f4 100644 --- a/constants/constants.go +++ b/constants/constants.go @@ -76,6 +76,8 @@ const ( ENCRYPTION_TYPE_NIP44_V2 = "nip44_v2" ) +const METADATA_APPSTORE_APP_ID_KEY = "app_store_app_id" + const SUBWALLET_APPSTORE_APP_ID = "uncle-jim" const ( diff --git a/db/db_migrate.go b/db/db_migrate.go new file mode 100644 index 00000000..666e77c3 --- /dev/null +++ b/db/db_migrate.go @@ -0,0 +1,235 @@ +package db + +import ( + "fmt" + "slices" + + "gorm.io/gorm" + + "github.com/getAlby/hub/logger" +) + +var expectedTables = []string{ + "apps", + "app_permissions", + "request_events", + "response_events", + "transactions", + "swaps", + "user_configs", + "migrations", + "forwards", +} + +// MigrateDB copies all rows from one database to another. Both databases +// must have an up-to-date schema (they are checked against expectedTables). +// Orphaned request and response events are deleted from the source database +// before copying, as they would violate foreign key constraints in the +// destination database. +func MigrateDB(from, to *gorm.DB) error { + if err := checkSchema(from); err != nil { + return fmt.Errorf("source database schema check failed: %w", err) + } + + if err := checkSchema(to); err != nil { + return fmt.Errorf("destination database schema check failed: %w", err) + } + + // NOTE: we assume that excess request events have already been cleaned up due to the background task + // and only a maximum of ~1000 remain. + logger.Logger.Info("Deleting orphaned request events.") + err := from.Exec("DELETE FROM request_events WHERE app_id NOT IN (SELECT id FROM apps);").Error + if err != nil { + return fmt.Errorf("failed to delete orphaned request events: %w", err) + } + + // NOTE: we assume that excess response events have already been cleaned up due to the background task + // and only a maximum of ~1000 remain. + logger.Logger.Info("Deleting orphaned response events.") + err = from.Exec("DELETE FROM response_events WHERE request_id NOT IN (SELECT id FROM request_events);").Error + if err != nil { + return fmt.Errorf("failed to delete orphaned response events: %w", err) + } + + tx := to.Begin() + defer tx.Rollback() + + if err := tx.Error; err != nil { + return fmt.Errorf("failed to start transaction: %w", err) + } + + // Table migration order matters: referenced tables must be migrated + // before referencing tables. + + logger.Logger.Info("migrating apps...") + if err := migrateTable[App](from, tx); err != nil { + return fmt.Errorf("failed to migrate apps: %w", err) + } + + logger.Logger.Info("migrating app_permissions...") + if err := migrateTable[AppPermission](from, tx); err != nil { + return fmt.Errorf("failed to migrate app_permissions: %w", err) + } + + logger.Logger.Info("migrating request_events...") + if err := migrateTable[RequestEvent](from, tx); err != nil { + return fmt.Errorf("failed to migrate request_events: %w", err) + } + + logger.Logger.Info("migrating response_events...") + if err := migrateTable[ResponseEvent](from, tx); err != nil { + return fmt.Errorf("failed to migrate response_events: %w", err) + } + + logger.Logger.Info("migrating transactions...") + if err := migrateTable[Transaction](from, tx); err != nil { + return fmt.Errorf("failed to migrate transactions: %w", err) + } + + logger.Logger.Info("migrating swaps...") + if err := migrateTable[Swap](from, tx); err != nil { + return fmt.Errorf("failed to migrate swaps: %w", err) + } + + logger.Logger.Info("migrating forwards...") + if err := migrateTable[Forward](from, tx); err != nil { + return fmt.Errorf("failed to migrate forwards: %w", err) + } + + logger.Logger.Info("migrating user_configs...") + if err := migrateTable[UserConfig](from, tx); err != nil { + return fmt.Errorf("failed to migrate user_configs: %w", err) + } + + if to.Dialector.Name() == "postgres" { + logger.Logger.Info("resetting sequences...") + if err := resetSequences(tx); err != nil { + return fmt.Errorf("failed to reset sequences: %w", err) + } + } + + tx.Commit() + if err := tx.Error; err != nil { + return fmt.Errorf("failed to commit transaction: %w", err) + } + + return nil +} + +func migrateTable[T any](from, to *gorm.DB) error { + var data []T + if err := from.Find(&data).Error; err != nil { + return fmt.Errorf("failed to fetch data: %w", err) + } + + if len(data) == 0 { + return nil + } + + // to avoid "failed to migrate transactions: failed to insert data: extended protocol limited to 65535 parameters" + // see https://stackoverflow.com/questions/77372430/extended-protocol-limited-to-65535-parameters-golang-gorm + // max statements is 65535 + // but it's the number of records * columns + // to be safe, using a lower value of 1000. + // this will fail if any table has more than 65 columns, which I doubt we will have + max := 1000 + for i := 0; i < len(data); i += max { + j := min(i+max, len(data)) + + if err := to.Create(data[i:j]).Error; err != nil { + return fmt.Errorf("failed to insert data: %w", err) + } + } + + return nil +} + +func checkSchema(db *gorm.DB) error { + tables, err := listTables(db) + if err != nil { + return fmt.Errorf("failed to list database tables: %w", err) + } + + for _, table := range expectedTables { + if !slices.Contains(tables, table) { + return fmt.Errorf("table missing from the database: %q", table) + } + } + + for _, table := range tables { + if !slices.Contains(expectedTables, table) { + return fmt.Errorf("unexpected table found in the database: %q", table) + } + } + + return nil +} + +func listTables(db *gorm.DB) ([]string, error) { + var query string + + switch db.Dialector.Name() { + case "sqlite": + query = "SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%';" + case "postgres": + query = "SELECT tablename FROM pg_tables WHERE schemaname = 'public';" + default: + return nil, fmt.Errorf("unsupported database: %q", db.Dialector.Name()) + } + + rows, err := db.Raw(query).Rows() + if err != nil { + return nil, fmt.Errorf("failed to query table names: %w", err) + } + defer func() { + if err := rows.Close(); err != nil { + logger.Logger.WithError(err).Error("failed to close rows") + } + }() + + var tables []string + for rows.Next() { + var table string + if err := rows.Scan(&table); err != nil { + return nil, fmt.Errorf("failed to scan table name: %w", err) + } + tables = append(tables, table) + } + + return tables, nil +} + +func resetSequences(db *gorm.DB) error { + type resetReq struct { + table string + seq string + } + + resetReqs := []resetReq{ + {"apps", "apps_2_id_seq"}, + {"app_permissions", "app_permissions_2_id_seq"}, + {"request_events", "request_events_id_seq"}, + {"response_events", "response_events_id_seq"}, + {"transactions", "transactions_id_seq"}, + {"swaps", "swaps_id_seq"}, + {"forwards", "forwards_id_seq"}, + {"user_configs", "user_configs_id_seq"}, + } + + for _, req := range resetReqs { + if err := resetPostgresSequence(db, req.table, req.seq); err != nil { + return fmt.Errorf("failed to reset sequence %q for %q: %w", req.seq, req.table, err) + } + } + + return nil +} + +func resetPostgresSequence(db *gorm.DB, table string, seq string) error { + query := fmt.Sprintf("SELECT setval('%s', (SELECT MAX(id) FROM %s));", seq, table) + if err := db.Exec(query).Error; err != nil { + return fmt.Errorf("failed to execute setval(): %w", err) + } + + return nil +} diff --git a/db/migrations/202604081200_app_last_settled_transaction.go b/db/migrations/202604081200_app_last_settled_transaction.go new file mode 100644 index 00000000..f6e57b7b --- /dev/null +++ b/db/migrations/202604081200_app_last_settled_transaction.go @@ -0,0 +1,29 @@ +package migrations + +import ( + _ "embed" + "text/template" + + "github.com/go-gormigrate/gormigrate/v2" + "gorm.io/gorm" +) + +const appLastSettledTransactionMigration = `ALTER TABLE apps ADD COLUMN last_settled_transaction_at {{ .Timestamp }};` + +var appLastSettledTransactionMigrationTmpl = template.Must(template.New("appLastSettledTransactionMigration").Parse(appLastSettledTransactionMigration)) + +var _202604081200_app_last_settled_transaction = &gormigrate.Migration{ + ID: "202604081200_app_last_settled_transaction", + Migrate: func(tx *gorm.DB) error { + + err := exec(tx, appLastSettledTransactionMigrationTmpl) + if err != nil { + return err + } + + return nil + }, + Rollback: func(tx *gorm.DB) error { + return nil + }, +} diff --git a/db/migrations/migrate.go b/db/migrations/migrate.go index 6985149a..89158a40 100644 --- a/db/migrations/migrate.go +++ b/db/migrations/migrate.go @@ -38,6 +38,7 @@ func Migrate(gormDB *gorm.DB) error { _202508151405_swap_xpub, _202508192137_forwards, _202509031250_transactions_updated_at_index, + _202604081200_app_last_settled_transaction, }) return m.Migrate() diff --git a/db/models.go b/db/models.go index 65567f3a..be60711b 100644 --- a/db/models.go +++ b/db/models.go @@ -16,16 +16,17 @@ type UserConfig struct { } type App struct { - ID uint - Name string `validate:"required"` - Description string - AppPubkey string `validate:"required"` - WalletPubkey *string - CreatedAt time.Time - UpdatedAt time.Time - LastUsedAt *time.Time - Isolated bool - Metadata datatypes.JSON + ID uint + Name string `validate:"required"` + Description string + AppPubkey string `validate:"required"` + WalletPubkey *string + CreatedAt time.Time + UpdatedAt time.Time + LastUsedAt *time.Time + LastSettledTransactionAt *time.Time + Isolated bool + Metadata datatypes.JSON } type AppPermission struct { @@ -96,8 +97,8 @@ type Swap struct { Type string State string Invoice string - SendAmount uint64 - ReceiveAmount uint64 + SendAmountSat uint64 `gorm:"column:send_amount"` + ReceiveAmountSat uint64 `gorm:"column:receive_amount"` Preimage string PaymentHash string DestinationAddress string diff --git a/db/queries/get_budget_usage.go b/db/queries/get_budget_usage.go index 20d72b76..0fa57461 100644 --- a/db/queries/get_budget_usage.go +++ b/db/queries/get_budget_usage.go @@ -8,15 +8,18 @@ import ( "gorm.io/gorm" ) -func GetBudgetUsageSat(tx *gorm.DB, appPermission *db.AppPermission) uint64 { +func GetBudgetUsageMsat(tx *gorm.DB, appPermission *db.AppPermission) (uint64, error) { var result struct { Sum uint64 } - tx. + err := tx. Table("transactions"). Select("SUM(amount_msat + fee_msat + fee_reserve_msat) as sum"). - Where("app_id = ? AND type = ? AND (state = ? OR state = ?) AND created_at > ?", appPermission.AppId, constants.TRANSACTION_TYPE_OUTGOING, constants.TRANSACTION_STATE_SETTLED, constants.TRANSACTION_STATE_PENDING, getStartOfBudget(appPermission.BudgetRenewal)).Scan(&result) - return result.Sum / 1000 + Where("app_id = ? AND type = ? AND (state = ? OR state = ?) AND created_at > ?", appPermission.AppId, constants.TRANSACTION_TYPE_OUTGOING, constants.TRANSACTION_STATE_SETTLED, constants.TRANSACTION_STATE_PENDING, getStartOfBudget(appPermission.BudgetRenewal)).Scan(&result).Error + if err != nil { + return 0, err + } + return result.Sum, nil } func getStartOfBudget(budget_type string) time.Time { diff --git a/db/queries/get_budget_usage_test.go b/db/queries/get_budget_usage_test.go new file mode 100644 index 00000000..5a4eba1d --- /dev/null +++ b/db/queries/get_budget_usage_test.go @@ -0,0 +1,234 @@ +package queries + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/getAlby/hub/constants" + "github.com/getAlby/hub/db" + "github.com/getAlby/hub/tests" +) + +func TestGetBudgetUsage_IncludesPendingAndSettledOutgoing(t *testing.T) { + svc, err := tests.CreateTestService(t) + require.NoError(t, err) + defer svc.Remove() + + app, _, err := tests.CreateApp(svc) + require.NoError(t, err) + + appPermission := &db.AppPermission{ + AppId: app.ID, + App: *app, + Scope: constants.PAY_INVOICE_SCOPE, + BudgetRenewal: constants.BUDGET_RENEWAL_NEVER, + } + + require.NoError(t, svc.DB.Create(&db.Transaction{ + AppId: &app.ID, + Type: constants.TRANSACTION_TYPE_OUTGOING, + State: constants.TRANSACTION_STATE_PENDING, + AmountMsat: 50000, + FeeMsat: 1000, + FeeReserveMsat: 2000, + }).Error) + + require.NoError(t, svc.DB.Create(&db.Transaction{ + AppId: &app.ID, + Type: constants.TRANSACTION_TYPE_OUTGOING, + State: constants.TRANSACTION_STATE_SETTLED, + AmountMsat: 25000, + FeeMsat: 500, + FeeReserveMsat: 500, + }).Error) + + budgetUsageMsat, err := GetBudgetUsageMsat(svc.DB, appPermission) + require.NoError(t, err) + assert.Equal(t, uint64(79000), budgetUsageMsat) +} + +func TestGetBudgetUsage_ExcludesWrongStateTypeAndApp(t *testing.T) { + svc, err := tests.CreateTestService(t) + require.NoError(t, err) + defer svc.Remove() + + app, _, err := tests.CreateApp(svc) + require.NoError(t, err) + + otherApp, _, err := tests.CreateApp(svc) + require.NoError(t, err) + + appPermission := &db.AppPermission{ + AppId: app.ID, + App: *app, + Scope: constants.PAY_INVOICE_SCOPE, + BudgetRenewal: constants.BUDGET_RENEWAL_NEVER, + } + + require.NoError(t, svc.DB.Create(&db.Transaction{ + AppId: &app.ID, + Type: constants.TRANSACTION_TYPE_OUTGOING, + State: constants.TRANSACTION_STATE_SETTLED, + AmountMsat: 20000, + FeeMsat: 1000, + FeeReserveMsat: 0, + }).Error) + + require.NoError(t, svc.DB.Create(&db.Transaction{ + AppId: &app.ID, + Type: constants.TRANSACTION_TYPE_INCOMING, + State: constants.TRANSACTION_STATE_SETTLED, + AmountMsat: 90000, + FeeMsat: 0, + FeeReserveMsat: 0, + }).Error) + + require.NoError(t, svc.DB.Create(&db.Transaction{ + AppId: &app.ID, + Type: constants.TRANSACTION_TYPE_OUTGOING, + State: constants.TRANSACTION_STATE_FAILED, + AmountMsat: 90000, + FeeMsat: 0, + FeeReserveMsat: 0, + }).Error) + + require.NoError(t, svc.DB.Create(&db.Transaction{ + AppId: &otherApp.ID, + Type: constants.TRANSACTION_TYPE_OUTGOING, + State: constants.TRANSACTION_STATE_SETTLED, + AmountMsat: 90000, + FeeMsat: 0, + FeeReserveMsat: 0, + }).Error) + + budgetUsageMsat, err := GetBudgetUsageMsat(svc.DB, appPermission) + require.NoError(t, err) + assert.Equal(t, uint64(21000), budgetUsageMsat) +} + +func TestGetBudgetUsage_BudgetWindowDaily(t *testing.T) { + svc, err := tests.CreateTestService(t) + require.NoError(t, err) + defer svc.Remove() + + app, _, err := tests.CreateApp(svc) + require.NoError(t, err) + + appPermissionDaily := &db.AppPermission{ + AppId: app.ID, + App: *app, + Scope: constants.PAY_INVOICE_SCOPE, + BudgetRenewal: constants.BUDGET_RENEWAL_DAILY, + } + + dailyStart := getStartOfBudget(constants.BUDGET_RENEWAL_DAILY) + + require.NoError(t, svc.DB.Create(&db.Transaction{ + AppId: &app.ID, + Type: constants.TRANSACTION_TYPE_OUTGOING, + State: constants.TRANSACTION_STATE_SETTLED, + AmountMsat: 20000, + FeeMsat: 1000, + FeeReserveMsat: 0, + CreatedAt: dailyStart.Add(1 * time.Minute), + }).Error) + + require.NoError(t, svc.DB.Create(&db.Transaction{ + AppId: &app.ID, + Type: constants.TRANSACTION_TYPE_OUTGOING, + State: constants.TRANSACTION_STATE_PENDING, + AmountMsat: 20000, + FeeMsat: 0, + FeeReserveMsat: 1000, + CreatedAt: dailyStart.Add(2 * time.Hour), + }).Error) + + budgetUsageMsat, err := GetBudgetUsageMsat(svc.DB, appPermissionDaily) + require.NoError(t, err) + assert.Equal(t, uint64(42000), budgetUsageMsat) +} + +func TestGetBudgetUsage_BudgetWindowWeekly(t *testing.T) { + svc, err := tests.CreateTestService(t) + require.NoError(t, err) + defer svc.Remove() + + app, _, err := tests.CreateApp(svc) + require.NoError(t, err) + + appPermissionWeekly := &db.AppPermission{ + AppId: app.ID, + App: *app, + Scope: constants.PAY_INVOICE_SCOPE, + BudgetRenewal: constants.BUDGET_RENEWAL_WEEKLY, + } + + weeklyStart := getStartOfBudget(constants.BUDGET_RENEWAL_WEEKLY) + + require.NoError(t, svc.DB.Create(&db.Transaction{ + AppId: &app.ID, + Type: constants.TRANSACTION_TYPE_OUTGOING, + State: constants.TRANSACTION_STATE_SETTLED, + AmountMsat: 10000, + FeeMsat: 1000, + FeeReserveMsat: 0, + CreatedAt: weeklyStart.Add(30 * time.Minute), + }).Error) + + require.NoError(t, svc.DB.Create(&db.Transaction{ + AppId: &app.ID, + Type: constants.TRANSACTION_TYPE_OUTGOING, + State: constants.TRANSACTION_STATE_PENDING, + AmountMsat: 7000, + FeeMsat: 0, + FeeReserveMsat: 0, + CreatedAt: weeklyStart.Add(-1 * time.Hour), + }).Error) + + budgetUsageMsat, err := GetBudgetUsageMsat(svc.DB, appPermissionWeekly) + require.NoError(t, err) + assert.Equal(t, uint64(11000), budgetUsageMsat) +} + +func TestGetBudgetUsage_BudgetWindowNever(t *testing.T) { + svc, err := tests.CreateTestService(t) + require.NoError(t, err) + defer svc.Remove() + + app, _, err := tests.CreateApp(svc) + require.NoError(t, err) + + appPermission := &db.AppPermission{ + AppId: app.ID, + App: *app, + Scope: constants.PAY_INVOICE_SCOPE, + BudgetRenewal: constants.BUDGET_RENEWAL_NEVER, + } + + require.NoError(t, svc.DB.Create(&db.Transaction{ + AppId: &app.ID, + Type: constants.TRANSACTION_TYPE_OUTGOING, + State: constants.TRANSACTION_STATE_SETTLED, + AmountMsat: 5000, + FeeMsat: 1000, + FeeReserveMsat: 0, + CreatedAt: time.Now().AddDate(-2, 0, 0), + }).Error) + + require.NoError(t, svc.DB.Create(&db.Transaction{ + AppId: &app.ID, + Type: constants.TRANSACTION_TYPE_OUTGOING, + State: constants.TRANSACTION_STATE_PENDING, + AmountMsat: 4000, + FeeMsat: 0, + FeeReserveMsat: 1000, + CreatedAt: time.Now().AddDate(-1, 0, 0), + }).Error) + + budgetUsageMsat, err := GetBudgetUsageMsat(svc.DB, appPermission) + require.NoError(t, err) + assert.Equal(t, uint64(11000), budgetUsageMsat) +} diff --git a/db/queries/get_isolated_balance.go b/db/queries/get_isolated_balance.go index 331b598e..ab5dcd7c 100644 --- a/db/queries/get_isolated_balance.go +++ b/db/queries/get_isolated_balance.go @@ -5,23 +5,29 @@ import ( "gorm.io/gorm" ) -func GetIsolatedBalance(tx *gorm.DB, appId uint) int64 { +func GetIsolatedBalanceMsat(tx *gorm.DB, appId uint) (int64, error) { var received struct { Sum int64 } - tx. + err := tx. Table("transactions"). Select("SUM(amount_msat) as sum"). - Where("app_id = ? AND type = ? AND state = ?", appId, constants.TRANSACTION_TYPE_INCOMING, constants.TRANSACTION_STATE_SETTLED).Scan(&received) + Where("app_id = ? AND type = ? AND state = ?", appId, constants.TRANSACTION_TYPE_INCOMING, constants.TRANSACTION_STATE_SETTLED).Scan(&received).Error + if err != nil { + return 0, err + } var spent struct { Sum int64 } - tx. + err = tx. Table("transactions"). Select("SUM(amount_msat + fee_msat + fee_reserve_msat) as sum"). - Where("app_id = ? AND type = ? AND (state = ? OR state = ?)", appId, constants.TRANSACTION_TYPE_OUTGOING, constants.TRANSACTION_STATE_SETTLED, constants.TRANSACTION_STATE_PENDING).Scan(&spent) + Where("app_id = ? AND type = ? AND (state = ? OR state = ?)", appId, constants.TRANSACTION_TYPE_OUTGOING, constants.TRANSACTION_STATE_SETTLED, constants.TRANSACTION_STATE_PENDING).Scan(&spent).Error + if err != nil { + return 0, err + } - return received.Sum - spent.Sum + return received.Sum - spent.Sum, nil } diff --git a/db/queries/get_isolated_balance_test.go b/db/queries/get_isolated_balance_test.go index 0a3cff60..925b0f36 100644 --- a/db/queries/get_isolated_balance_test.go +++ b/db/queries/get_isolated_balance_test.go @@ -36,8 +36,9 @@ func TestGetIsolatedBalance_PendingNoOverflow(t *testing.T) { } svc.DB.Save(&tx) - balance := GetIsolatedBalance(svc.DB, app.ID) - assert.Equal(t, int64(-11000), balance) + balanceMsat, err := GetIsolatedBalanceMsat(svc.DB, app.ID) + require.NoError(t, err) + assert.Equal(t, int64(-11000), balanceMsat) } func TestGetIsolatedBalance_SettledNoOverflow(t *testing.T) { @@ -65,6 +66,7 @@ func TestGetIsolatedBalance_SettledNoOverflow(t *testing.T) { } svc.DB.Save(&tx) - balance := GetIsolatedBalance(svc.DB, app.ID) - assert.Equal(t, int64(-1000), balance) + balanceMsat, err := GetIsolatedBalanceMsat(svc.DB, app.ID) + require.NoError(t, err) + assert.Equal(t, int64(-1000), balanceMsat) } diff --git a/db/queries/get_total_subwallet_balance.go b/db/queries/get_total_subwallet_balance.go new file mode 100644 index 00000000..22860c95 --- /dev/null +++ b/db/queries/get_total_subwallet_balance.go @@ -0,0 +1,40 @@ +package queries + +import ( + "github.com/getAlby/hub/constants" + "github.com/getAlby/hub/db" + "gorm.io/datatypes" + "gorm.io/gorm" +) + +func GetTotalSubwalletBalanceMsat(tx *gorm.DB) (int64, error) { + subwalletAppIDsQuery := tx.Model(&db.App{}). + Select("id"). + Where(datatypes.JSONQuery("metadata").Equals(constants.SUBWALLET_APPSTORE_APP_ID, constants.METADATA_APPSTORE_APP_ID_KEY)) + + var received struct { + Sum int64 + } + res := tx. + Table("transactions"). + Select("SUM(amount_msat) as sum"). + Where("app_id IN (?) AND type = ? AND state = ?", subwalletAppIDsQuery, constants.TRANSACTION_TYPE_INCOMING, constants.TRANSACTION_STATE_SETTLED). + Scan(&received) + if res.Error != nil { + return 0, res.Error + } + + var spent struct { + Sum int64 + } + res = tx. + Table("transactions"). + Select("SUM(amount_msat + fee_msat + fee_reserve_msat) as sum"). + Where("app_id IN (?) AND type = ? AND (state = ? OR state = ?)", subwalletAppIDsQuery, constants.TRANSACTION_TYPE_OUTGOING, constants.TRANSACTION_STATE_SETTLED, constants.TRANSACTION_STATE_PENDING). + Scan(&spent) + if res.Error != nil { + return 0, res.Error + } + + return received.Sum - spent.Sum, nil +} diff --git a/db/queries/get_total_subwallet_balance_test.go b/db/queries/get_total_subwallet_balance_test.go new file mode 100644 index 00000000..a5a1bf41 --- /dev/null +++ b/db/queries/get_total_subwallet_balance_test.go @@ -0,0 +1,64 @@ +package queries + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gorm.io/datatypes" + + "github.com/getAlby/hub/constants" + "github.com/getAlby/hub/db" + "github.com/getAlby/hub/tests" +) + +func TestGetTotalSubwalletBalance(t *testing.T) { + svc, err := tests.CreateTestService(t) + require.NoError(t, err) + defer svc.Remove() + + subwalletA, _, err := tests.CreateApp(svc) + require.NoError(t, err) + subwalletA.Isolated = true + subwalletA.Metadata = datatypes.JSON([]byte(fmt.Sprintf(`{"%s":"%s"}`, constants.METADATA_APPSTORE_APP_ID_KEY, constants.SUBWALLET_APPSTORE_APP_ID))) + svc.DB.Save(&subwalletA) + + subwalletB, _, err := tests.CreateApp(svc) + require.NoError(t, err) + subwalletB.Isolated = true + subwalletB.Metadata = datatypes.JSON([]byte(fmt.Sprintf(`{"%s":"%s"}`, constants.METADATA_APPSTORE_APP_ID_KEY, constants.SUBWALLET_APPSTORE_APP_ID))) + svc.DB.Save(&subwalletB) + + incomingSubwalletTx := db.Transaction{ + AppId: &subwalletA.ID, + Type: constants.TRANSACTION_TYPE_INCOMING, + State: constants.TRANSACTION_STATE_SETTLED, + AmountMsat: 5000, + } + svc.DB.Save(&incomingSubwalletTx) + + outgoingSettledSubwalletTx := db.Transaction{ + AppId: &subwalletA.ID, + Type: constants.TRANSACTION_TYPE_OUTGOING, + State: constants.TRANSACTION_STATE_SETTLED, + AmountMsat: 1000, + FeeMsat: 100, + FeeReserveMsat: 0, + } + svc.DB.Save(&outgoingSettledSubwalletTx) + + outgoingPendingSubwalletTx := db.Transaction{ + AppId: &subwalletB.ID, + Type: constants.TRANSACTION_TYPE_OUTGOING, + State: constants.TRANSACTION_STATE_PENDING, + AmountMsat: 2000, + FeeMsat: 0, + FeeReserveMsat: 300, + } + svc.DB.Save(&outgoingPendingSubwalletTx) + + totalBalanceMsat, err := GetTotalSubwalletBalanceMsat(svc.DB) + require.NoError(t, err) + assert.Equal(t, int64(1600), totalBalanceMsat) +} diff --git a/frontend/.env.local.example b/frontend/.env.local.example index fb000681..4bc19c19 100644 --- a/frontend/.env.local.example +++ b/frontend/.env.local.example @@ -1,2 +1,2 @@ # set a custom messageboard wallet (should be a sub-wallet with only make invoice and list transactions permissions) -#VITE_LIGHTNING_MESSAGEBOARD_NWC_URL="nostr+walletconnect://5f8e7c098137ccca853327be44a9b2e956cf79a8e2336e27a4f27b3fb55325b6?relay=wss://relay.getalby.com/v1&secret=ace5c4b9e08138a2ef91b4ccf1379952c77c651866b29f5872b5165134417894" \ No newline at end of file +#VITE_LIGHTNING_MESSAGEBOARD_NWC_URL="nostr+walletconnect://5f8e7c098137ccca853327be44a9b2e956cf79a8e2336e27a4f27b3fb55325b6?relay=wss://relay.getalby.com&relay=wss://relay2.getalby.com&secret=ace5c4b9e08138a2ef91b4ccf1379952c77c651866b29f5872b5165134417894" \ No newline at end of file diff --git a/frontend/components.json b/frontend/components.json index da828e9e..e6938bbd 100644 --- a/frontend/components.json +++ b/frontend/components.json @@ -4,7 +4,7 @@ "rsc": false, "tsx": true, "tailwind": { - "config": "tailwind.config.js", + "config": "", "css": "src/index.css", "baseColor": "zinc", "cssVariables": true, diff --git a/frontend/eslint.config.mjs b/frontend/eslint.config.mjs index 11d0af74..198fb90f 100644 --- a/frontend/eslint.config.mjs +++ b/frontend/eslint.config.mjs @@ -1,20 +1,10 @@ -import { fixupConfigRules, fixupPluginRules } from "@eslint/compat"; -import { FlatCompat } from "@eslint/eslintrc"; -import js from "@eslint/js"; -import typescriptEslint from "@typescript-eslint/eslint-plugin"; +import eslint from "@eslint/js"; import tsParser from "@typescript-eslint/parser"; +import eslintConfigPrettier from "eslint-config-prettier/flat"; +import reactHooks from "eslint-plugin-react-hooks"; import reactRefresh from "eslint-plugin-react-refresh"; import globals from "globals"; -import path from "node:path"; -import { fileURLToPath } from "node:url"; -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); - -const compat = new FlatCompat({ - baseDirectory: __dirname, - recommendedConfig: js.configs.recommended, - allConfig: js.configs.all, -}); +import tseslint from "typescript-eslint"; export default [ { @@ -25,20 +15,12 @@ export default [ "src/components/ui/navigation-menu.tsx", ], }, - ...fixupConfigRules( - compat.extends( - "eslint:recommended", - "plugin:@typescript-eslint/recommended", - "plugin:react-hooks/recommended", - "prettier" - ) - ), + eslint.configs.recommended, + ...tseslint.configs.recommended, + reactRefresh.configs.vite, + reactHooks.configs.flat["recommended-latest"], + eslintConfigPrettier, { - plugins: { - "react-refresh": reactRefresh, - "@typescript-eslint": fixupPluginRules(typescriptEslint), - }, - languageOptions: { globals: { ...globals.browser, @@ -48,20 +30,7 @@ export default [ }, files: ["**/*.ts", "**/*.tsx"], rules: { - "react-refresh/only-export-components": [ - "warn", - { - allowConstantExport: true, - }, - ], - - "@typescript-eslint/ban-ts-comment": [ - "error", - { - "ts-ignore": "allow-with-description", - }, - ], - + "react-hooks/set-state-in-effect": "off", "@typescript-eslint/no-unused-vars": [ "warn", { diff --git a/frontend/package.json b/frontend/package.json index 5c837bfa..3109831a 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -20,97 +20,63 @@ "prepare": "cd .. && husky frontend/.husky" }, "dependencies": { - "@getalby/lightning-tools": "^6.0.0", - "@getalby/sdk": "^6.0.1", - "@hookform/resolvers": "^5.1.1", - "@radix-ui/react-accordion": "^1.2.12", - "@radix-ui/react-alert-dialog": "^1.1.14", - "@radix-ui/react-aspect-ratio": "^1.1.7", - "@radix-ui/react-avatar": "^1.1.11", - "@radix-ui/react-checkbox": "^1.3.2", - "@radix-ui/react-collapsible": "^1.1.11", - "@radix-ui/react-context-menu": "^2.2.16", - "@radix-ui/react-dialog": "^1.1.15", - "@radix-ui/react-dropdown-menu": "^2.1.15", - "@radix-ui/react-hover-card": "^1.1.15", - "@radix-ui/react-label": "^2.1.7", - "@radix-ui/react-menubar": "^1.1.16", - "@radix-ui/react-navigation-menu": "^1.2.13", - "@radix-ui/react-popover": "^1.1.14", - "@radix-ui/react-progress": "^1.1.7", - "@radix-ui/react-radio-group": "^1.3.8", - "@radix-ui/react-scroll-area": "^1.2.9", - "@radix-ui/react-select": "^2.2.5", - "@radix-ui/react-separator": "^1.1.7", - "@radix-ui/react-slider": "^1.3.5", - "@radix-ui/react-slot": "^1.2.3", - "@radix-ui/react-switch": "^1.2.6", - "@radix-ui/react-tabs": "^1.1.12", - "@radix-ui/react-toast": "^1.2.14", - "@radix-ui/react-toggle": "^1.1.9", - "@radix-ui/react-toggle-group": "^1.1.10", - "@radix-ui/react-tooltip": "^1.2.7", - "@scure/bip39": "^2.0.1", - "@stepperize/react": "^5.1.8", - "argon2-wasm-esm": "^1.0.3", + "@base-ui/react": "^1.5.0", + "@fontsource-variable/figtree": "^5.3.0", + "@fontsource-variable/inter": "^5.3.0", + "@getalby/lightning-tools": "^8.1.0", + "@getalby/sdk": "^8.0.3", + "@scure/bip39": "^2.2.0", + "@stepperize/react": "^6.1.0", "bitcoin-address-validation": "^3.0.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", "compare-versions": "^6.1.1", "date-fns": "^4.1.0", - "dayjs": "^1.11.10", + "dayjs": "^1.11.20", "embla-carousel-react": "^8.6.0", - "input-otp": "^1.4.2", - "lucide-react": "^0.544.0", - "next-themes": "^0.4.6", - "react": "18.3.1", - "react-day-picker": "^9.11.0", - "react-dom": "18.3.1", - "react-hook-form": "^7.60.0", - "react-lottie": "^1.2.4", - "react-qr-code": "^2.0.12", - "react-resizable-panels": "^3.0.6", - "react-router-dom": "^6.21.0", - "recharts": "2.15.4", + "lottie-react": "^2.4.1", + "lucide-react": "^1.28.0", + "qr-code-styling": "^1.9.2", + "radix-ui": "^1.4.3", + "react": "^19.2.6", + "react-day-picker": "^9.14.0", + "react-dom": "^19.2.6", + "react-router": "^7.18.2", "sonner": "^2.0.7", - "swr": "^2.3.6", - "tailwind-merge": "^3.3.1", - "tw-animate-css": "^1.3.5", + "swr": "^2.4.1", + "tailwind-merge": "^3.6.0", + "tw-animate-css": "^1.4.0", "vaul": "^1.1.2", - "zod": "^4.0.2", - "zustand": "^4.5.0" + "zustand": "^5.0.12" }, "devDependencies": { - "@commitlint/cli": "^19.3.0", - "@commitlint/config-conventional": "^20.0.0", - "@eslint/compat": "^1.0.3", - "@eslint/eslintrc": "^3.1.0", - "@eslint/js": "^9.4.0", + "@commitlint/cli": "^20.5.3", + "@commitlint/config-conventional": "^21.2.0", + "@eslint/eslintrc": "^3.3.5", + "@eslint/js": "^10.0.1", "@tailwindcss/aspect-ratio": "^0.4.2", - "@tailwindcss/forms": "^0.5.7", + "@tailwindcss/forms": "^0.5.11", "@tailwindcss/typography": "^0.5.19", - "@tailwindcss/vite": "^4.1.11", - "@types/node": "^24.7.2", - "@types/react": "^18.2.15", - "@types/react-dom": "^18.2.7", - "@types/react-lottie": "^1.2.10", - "@typescript-eslint/eslint-plugin": "^7.11.0", - "@typescript-eslint/parser": "^7.11.0", - "@vitejs/plugin-react-swc": "^3.3.2", - "eslint": "^9.4.0", + "@tailwindcss/vite": "^4.2.4", + "@types/node": "^25.9.3", + "@types/react": "^19.2.14", + "@types/react-dom": "^19.0.0", + "@vitejs/plugin-react-swc": "^4.3.1", + "eslint": "^10.4.1", "eslint-config-prettier": "^10.1.8", - "eslint-plugin-react-hooks": "^7.0.1", - "eslint-plugin-react-refresh": "^0.4.3", - "globals": "^15.4.0", + "eslint-plugin-react-hooks": "^7.1.1", + "eslint-plugin-react-refresh": "^0.5.2", + "globals": "^17.4.0", "husky": "^9.0.11", - "lint-staged": "^15.2.5", - "prettier": "3.6.2", + "lint-staged": "^16.4.0", + "prettier": "3.8.3", "shx": "^0.4.0", - "tailwindcss": "^4.1.16", + "tailwindcss": "^4.3.0", "typescript": "^5.9.3", - "vite": "^5.4.0", - "vite-plugin-pwa": "^0.20.1", - "vite-tsconfig-paths": "^5.1.4" - } + "typescript-eslint": "^8.61.0", + "vite": "^8.2.1", + "vite-plugin-pwa": "^1.3.0" + }, + "packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e" } diff --git a/frontend/platform_specific/wails/src/utils/request.ts b/frontend/platform_specific/wails/src/utils/request.ts index aef986bf..4d7d8dfb 100644 --- a/frontend/platform_specific/wails/src/utils/request.ts +++ b/frontend/platform_specific/wails/src/utils/request.ts @@ -10,7 +10,7 @@ export const request = async ( args[1]?.body?.toString() || "" ); - console.info("Wails request", ...args, res); + console.info("Wails request", args[0].toString(), args[1]?.method || "GET"); if (res.error) { throw new Error(res.error); } diff --git a/frontend/public/fonts/Inter-italic.var.woff2 b/frontend/public/fonts/Inter-italic.var.woff2 deleted file mode 100644 index 03875311..00000000 Binary files a/frontend/public/fonts/Inter-italic.var.woff2 and /dev/null differ diff --git a/frontend/public/fonts/Inter-roman.var.woff2 b/frontend/public/fonts/Inter-roman.var.woff2 deleted file mode 100644 index a6efdc48..00000000 Binary files a/frontend/public/fonts/Inter-roman.var.woff2 and /dev/null differ diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 2a90970e..8789bcf9 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,31 +1,32 @@ -import { - RouterProvider, - createBrowserRouter, - createHashRouter, -} from "react-router-dom"; +import { createBrowserRouter, createHashRouter } from "react-router"; +import { RouterProvider } from "react-router/dom"; import { Toaster } from "src/components/ui/sonner"; import { ThemeProvider } from "src/components/ui/theme-provider"; import { TouchProvider } from "src/components/ui/tooltip"; import { useInfo } from "src/hooks/useInfo"; +import { useRegisterProtocolHandler } from "src/hooks/useRegisterProtocolHandler"; import routes from "src/routes.tsx"; import { isHttpMode } from "src/utils/isHttpMode"; const createRouterFunc = isHttpMode() ? createBrowserRouter : createHashRouter; +const basePath = + import.meta.env.BASE_URL !== "/" ? import.meta.env.BASE_URL : ""; const router = createRouterFunc(routes, { // if running on a subpath, use the subpath as the router basename // BASE_URL is set via process.env.BASE_PATH, see https://vite.dev/guide/build#public-base-path - basename: - import.meta.env.BASE_URL !== "/" ? import.meta.env.BASE_URL : undefined, + basename: basePath || undefined, }); function App() { const { data: info } = useInfo(); + useRegisterProtocolHandler(basePath); + return ( <> diff --git a/frontend/src/assets/cards/2fiat.png b/frontend/src/assets/cards/2fiat.png new file mode 100644 index 00000000..dd6b3732 Binary files /dev/null and b/frontend/src/assets/cards/2fiat.png differ diff --git a/frontend/src/assets/cards/freedomia.png b/frontend/src/assets/cards/freedomia.png new file mode 100644 index 00000000..2b876b66 Binary files /dev/null and b/frontend/src/assets/cards/freedomia.png differ diff --git a/frontend/src/assets/cards/redotpay.png b/frontend/src/assets/cards/redotpay.png new file mode 100644 index 00000000..cc72cb4c Binary files /dev/null and b/frontend/src/assets/cards/redotpay.png differ diff --git a/frontend/src/assets/cards/solvocard.png b/frontend/src/assets/cards/solvocard.png new file mode 100644 index 00000000..b5a02889 Binary files /dev/null and b/frontend/src/assets/cards/solvocard.png differ diff --git a/frontend/src/assets/images/node/bark-dark.svg b/frontend/src/assets/images/node/bark-dark.svg new file mode 100644 index 00000000..8cc83e3a --- /dev/null +++ b/frontend/src/assets/images/node/bark-dark.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/frontend/src/assets/images/node/bark-light.svg b/frontend/src/assets/images/node/bark-light.svg new file mode 100644 index 00000000..1707c3b5 --- /dev/null +++ b/frontend/src/assets/images/node/bark-light.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/frontend/src/assets/images/node/cln.png b/frontend/src/assets/images/node/cln.png new file mode 100644 index 00000000..3410d29f Binary files /dev/null and b/frontend/src/assets/images/node/cln.png differ diff --git a/frontend/src/assets/lotties/success-check.json b/frontend/src/assets/lotties/success-check.json new file mode 100644 index 00000000..9eee6036 --- /dev/null +++ b/frontend/src/assets/lotties/success-check.json @@ -0,0 +1,8588 @@ +{ + "h": 1000, + "w": 1000, + "meta": { + "a": "Muzahid Ahinger", + "k": "check done ok complete checked", + "d": "Check, done, complete animated signs and symbols", + "g": "@lottiefiles/toolkit-js 0.57.1-beta.0", + "tc": "#ffffff" + }, + "layers": [ + { + "ty": 4, + "sr": 1, + "st": 2, + "op": 71, + "ip": 2, + "ln": "14", + "hasMask": false, + "ao": 0, + "ks": { + "a": { "a": 0, "k": [0, 0] }, + "s": { + "a": 1, + "k": [ + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [112.5, 112.5, 100], + "t": 2 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 3 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [164.167, 164.167, 100], + "t": 4 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [175.388, 175.388, 100], + "t": 5 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [183.898, 183.898, 100], + "t": 6 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [189.962, 189.962, 100], + "t": 7 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [193.86, 193.86, 100], + "t": 8 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [195.883, 195.883, 100], + "t": 9 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [196.315, 196.315, 100], + "t": 10 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [195.434, 195.434, 100], + "t": 11 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [193.5, 193.5, 100], + "t": 12 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [190.758, 190.758, 100], + "t": 13 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [187.425, 187.425, 100], + "t": 14 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [183.699, 183.699, 100], + "t": 15 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [179.749, 179.749, 100], + "t": 16 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [175.722, 175.722, 100], + "t": 17 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [171.739, 171.739, 100], + "t": 18 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [167.898, 167.898, 100], + "t": 19 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [164.277, 164.277, 100], + "t": 20 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [160.931, 160.931, 100], + "t": 21 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [157.9, 157.9, 100], + "t": 22 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [155.207, 155.207, 100], + "t": 23 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [152.864, 152.864, 100], + "t": 24 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150.87, 150.87, 100], + "t": 25 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [149.213, 149.213, 100], + "t": 26 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [147.878, 147.878, 100], + "t": 27 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [146.842, 146.842, 100], + "t": 28 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [146.077, 146.077, 100], + "t": 29 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [145.556, 145.556, 100], + "t": 30 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [145.248, 145.248, 100], + "t": 31 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [145.122, 145.122, 100], + "t": 32 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [145.149, 145.149, 100], + "t": 33 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [145.301, 145.301, 100], + "t": 34 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [145.55, 145.55, 100], + "t": 35 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [145.873, 145.873, 100], + "t": 36 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [146.248, 146.248, 100], + "t": 37 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [146.654, 146.654, 100], + "t": 38 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [147.077, 147.077, 100], + "t": 39 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [147.5, 147.5, 100], + "t": 40 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [147.914, 147.914, 100], + "t": 41 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [148.308, 148.308, 100], + "t": 42 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [148.676, 148.676, 100], + "t": 43 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [149.012, 149.012, 100], + "t": 44 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [149.314, 149.314, 100], + "t": 45 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [149.579, 149.579, 100], + "t": 46 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [149.808, 149.808, 100], + "t": 47 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 48 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150.157, 150.157, 100], + "t": 49 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150.282, 150.282, 100], + "t": 50 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150.377, 150.377, 100], + "t": 51 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150.444, 150.444, 100], + "t": 52 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150.487, 150.487, 100], + "t": 53 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150.51, 150.51, 100], + "t": 54 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150.515, 150.515, 100], + "t": 55 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150.505, 150.505, 100], + "t": 56 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150.483, 150.483, 100], + "t": 57 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150.453, 150.453, 100], + "t": 58 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150.416, 150.416, 100], + "t": 59 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150.374, 150.374, 100], + "t": 60 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150.33, 150.33, 100], + "t": 61 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150.286, 150.286, 100], + "t": 62 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150.241, 150.241, 100], + "t": 63 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150.199, 150.199, 100], + "t": 64 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150.159, 150.159, 100], + "t": 65 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150.121, 150.121, 100], + "t": 66 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150.088, 150.088, 100], + "t": 67 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150.058, 150.058, 100], + "t": 68 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150.032, 150.032, 100], + "t": 69 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150.01, 150.01, 100], + "t": 70 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [149.991, 149.991, 100], + "t": 71 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [149.976, 149.976, 100], + "t": 72 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [149.965, 149.965, 100], + "t": 73 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [149.956, 149.956, 100], + "t": 74 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [149.951, 149.951, 100], + "t": 75 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [149.947, 149.947, 100], + "t": 76 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [149.946, 149.946, 100], + "t": 77 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [149.946, 149.946, 100], + "t": 78 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [149.948, 149.948, 100], + "t": 79 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [149.951, 149.951, 100], + "t": 80 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [149.954, 149.954, 100], + "t": 81 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [149.958, 149.958, 100], + "t": 82 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [149.963, 149.963, 100], + "t": 83 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [149.968, 149.968, 100], + "t": 84 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [149.972, 149.972, 100], + "t": 85 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [149.977, 149.977, 100], + "t": 86 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [149.981, 149.981, 100], + "t": 87 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [149.985, 149.985, 100], + "t": 88 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [149.989, 149.989, 100], + "t": 89 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [149.992, 149.992, 100], + "t": 90 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [149.995, 149.995, 100], + "t": 91 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [149.998, 149.998, 100], + "t": 92 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 93 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150.002, 150.002, 100], + "t": 94 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150.003, 150.003, 100], + "t": 95 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150.004, 150.004, 100], + "t": 96 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150.005, 150.005, 100], + "t": 97 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150.005, 150.005, 100], + "t": 98 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150.006, 150.006, 100], + "t": 99 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150.006, 150.006, 100], + "t": 100 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150.006, 150.006, 100], + "t": 101 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150.005, 150.005, 100], + "t": 102 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150.005, 150.005, 100], + "t": 103 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150.005, 150.005, 100], + "t": 104 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150.004, 150.004, 100], + "t": 105 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150.004, 150.004, 100], + "t": 106 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150.003, 150.003, 100], + "t": 107 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150.003, 150.003, 100], + "t": 108 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150.002, 150.002, 100], + "t": 109 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150.002, 150.002, 100], + "t": 110 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150.001, 150.001, 100], + "t": 111 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150.001, 150.001, 100], + "t": 112 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150.001, 150.001, 100], + "t": 113 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 114 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 115 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 116 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 117 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 118 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 119 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [149.999, 149.999, 100], + "t": 120 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [149.999, 149.999, 100], + "t": 121 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [149.999, 149.999, 100], + "t": 122 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [149.999, 149.999, 100], + "t": 123 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [149.999, 149.999, 100], + "t": 124 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [149.999, 149.999, 100], + "t": 125 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [149.999, 149.999, 100], + "t": 126 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 127 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 128 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 129 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 130 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 131 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 132 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 133 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 134 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 135 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 136 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 137 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 138 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 139 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 140 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 141 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 142 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 143 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 144 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 145 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 146 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 147 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 148 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 149 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 150 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 151 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 152 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 153 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 154 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 155 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 156 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 157 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 158 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 159 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 160 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 161 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 162 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 163 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 164 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 165 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 166 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 167 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 168 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 169 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 170 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 171 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 172 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 173 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 174 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 175 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 176 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 177 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 178 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 179 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 180 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 181 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 182 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 183 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 184 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 185 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 186 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 187 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 188 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 189 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 190 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 191 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 192 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 193 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 194 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 195 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 196 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 197 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 198 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 199 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 200 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 201 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 202 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 203 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 204 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 205 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 206 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 207 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 208 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 209 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 210 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 211 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 212 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 213 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 214 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 215 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 216 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 217 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 218 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 219 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 220 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 221 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 222 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 223 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 224 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 225 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 226 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 227 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 228 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 229 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 230 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 231 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 232 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 233 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 234 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 235 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 236 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 237 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 238 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 239 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 240 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 241 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 242 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 243 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 244 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 245 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 246 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 247 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 248 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 249 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 250 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 251 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 252 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 253 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 254 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 255 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 256 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 257 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 258 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 259 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 260 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 261 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 262 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 263 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 264 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 265 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 266 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 267 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 268 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 269 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 270 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 271 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 272 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 273 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 274 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 275 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 276 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 277 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 278 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 279 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 280 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 281 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 282 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 283 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 284 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 285 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 286 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 287 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 288 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 289 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 290 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 291 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 292 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 293 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 294 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 295 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 296 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 297 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 298 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 299 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 300 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 301 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 302 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 303 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 304 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 305 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 306 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 307 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 308 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 309 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 310 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 311 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 312 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 313 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 314 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 315 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 316 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 317 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 318 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 319 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 320 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 321 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 322 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 323 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 324 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 325 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 326 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 327 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 328 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 329 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 330 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 331 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 332 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 333 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 334 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 335 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 336 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 337 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 338 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 339 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 340 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 341 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 342 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 343 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 344 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 345 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 346 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 347 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 348 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 349 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 350 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 351 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 352 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 353 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 354 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 355 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 356 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 357 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 358 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 359 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 360 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 361 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 362 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 363 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 364 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 365 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 366 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 367 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 368 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 369 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 370 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 371 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 372 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 373 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 374 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 375 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 376 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 377 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 378 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 379 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 380 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 381 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 382 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 383 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 384 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 385 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 386 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 387 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 388 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 389 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 390 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 391 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 392 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 393 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 394 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 395 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 396 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 397 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 398 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 399 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 400 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 401 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 402 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 403 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 404 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 405 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 406 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 407 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 408 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 409 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 410 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 411 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 412 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 413 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 414 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 415 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 416 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 417 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 418 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 419 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 420 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 421 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 422 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 423 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 424 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 425 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 426 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 427 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 428 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 429 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 430 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 431 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 432 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 433 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 434 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 435 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 436 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 437 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 438 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 439 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 440 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 441 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 442 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 443 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 444 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 445 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 446 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 447 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 448 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 449 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 450 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 451 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 452 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 453 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 454 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 455 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 456 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 457 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 458 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 459 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 460 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 461 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 462 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 463 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 464 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 465 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 466 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 467 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 468 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 469 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 470 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 471 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 472 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 473 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 474 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 475 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 476 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 477 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 478 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 479 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 480 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 481 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 482 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 483 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 484 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 485 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 486 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 487 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 488 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 489 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 490 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 491 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 492 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 493 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 494 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 495 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 496 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 497 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 498 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 499 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 500 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 501 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 502 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 503 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 504 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 505 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 506 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 507 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 508 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 509 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 510 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 511 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 512 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 513 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 514 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 515 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 516 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 517 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 518 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 519 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 520 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 521 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 522 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 523 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 524 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 525 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 526 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 527 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 528 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 529 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 530 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 531 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 532 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 533 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 534 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 535 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 536 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 537 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 538 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 539 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 540 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 541 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 542 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 543 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 544 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 545 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 546 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 547 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 548 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 549 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 550 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 551 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 552 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 553 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 554 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 555 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 556 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 557 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 558 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 559 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 560 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 561 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 562 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 563 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 564 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 565 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 566 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 567 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 568 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 569 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 570 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 571 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 572 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 573 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 574 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 575 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 576 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 577 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 578 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 579 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 580 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 581 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 582 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 583 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 584 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 585 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 586 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 587 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 588 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 589 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 590 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 591 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 592 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 593 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 594 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 595 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 596 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 597 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 598 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 599 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 600 + }, + { "s": [150, 150, 100], "t": 601 } + ] + }, + "p": { "a": 0, "k": [491, 480.676, 0] }, + "r": { "a": 0, "k": 0 }, + "o": { "a": 0, "k": 100 } + }, + "shapes": [ + { + "ty": "gr", + "it": [ + { + "ty": "sh", + "d": 1, + "ks": { + "a": 0, + "k": { + "c": false, + "i": [ + [0, 0], + [0, 0], + [0, 0] + ], + "o": [ + [0, 0], + [0, 0], + [0, 0] + ], + "v": [ + [-85.202, 28.7], + [-32.001, 83.001], + [90.251, -41.301] + ] + } + } + }, + { + "ty": "tr", + "a": { "a": 0, "k": [0, 0] }, + "s": { "a": 0, "k": [100, 100] }, + "p": { "a": 0, "k": [0, 0] }, + "r": { "a": 0, "k": 0 }, + "o": { "a": 0, "k": 100 } + } + ] + }, + { + "ty": "tm", + "e": { + "a": 1, + "k": [ + { + "o": { "x": 0.333, "y": 0 }, + "i": { "x": 0.667, "y": 1 }, + "s": [0], + "t": 6 + }, + { "s": [100], "t": 16 } + ] + }, + "o": { "a": 0, "k": 0 }, + "s": { "a": 0, "k": 0 }, + "m": 1 + }, + { + "ty": "st", + "lc": 2, + "lj": 2, + "ml": 4, + "o": { "a": 0, "k": 100 }, + "w": { "a": 0, "k": 40 }, + "c": { "a": 0, "k": [1, 1, 1] } + } + ], + "ind": 1 + }, + { + "ty": 4, + "sr": 1, + "st": 1, + "op": 71, + "ip": 0, + "ln": "13", + "hasMask": false, + "ao": 0, + "ks": { + "a": { "a": 0, "k": [0, 0] }, + "s": { + "a": 1, + "k": [ + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [90, 90, 100], + "t": 0 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [120, 120, 100], + "t": 1 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 2 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [161.334, 161.334, 100], + "t": 3 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [170.311, 170.311, 100], + "t": 4 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [177.119, 177.119, 100], + "t": 5 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [181.969, 181.969, 100], + "t": 6 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [185.088, 185.088, 100], + "t": 7 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [186.706, 186.706, 100], + "t": 8 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [187.052, 187.052, 100], + "t": 9 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [186.347, 186.347, 100], + "t": 10 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [184.8, 184.8, 100], + "t": 11 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [182.606, 182.606, 100], + "t": 12 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [179.94, 179.94, 100], + "t": 13 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [176.959, 176.959, 100], + "t": 14 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [173.799, 173.799, 100], + "t": 15 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [170.578, 170.578, 100], + "t": 16 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [167.391, 167.391, 100], + "t": 17 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [164.319, 164.319, 100], + "t": 18 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [161.421, 161.421, 100], + "t": 19 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [158.744, 158.744, 100], + "t": 20 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [156.32, 156.32, 100], + "t": 21 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [154.166, 154.166, 100], + "t": 22 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [152.291, 152.291, 100], + "t": 23 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150.696, 150.696, 100], + "t": 24 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [149.371, 149.371, 100], + "t": 25 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [148.302, 148.302, 100], + "t": 26 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [147.473, 147.473, 100], + "t": 27 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [146.862, 146.862, 100], + "t": 28 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [146.445, 146.445, 100], + "t": 29 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [146.198, 146.198, 100], + "t": 30 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [146.098, 146.098, 100], + "t": 31 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [146.119, 146.119, 100], + "t": 32 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [146.241, 146.241, 100], + "t": 33 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [146.44, 146.44, 100], + "t": 34 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [146.699, 146.699, 100], + "t": 35 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [146.998, 146.998, 100], + "t": 36 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [147.324, 147.324, 100], + "t": 37 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [147.661, 147.661, 100], + "t": 38 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [148, 148, 100], + "t": 39 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [148.331, 148.331, 100], + "t": 40 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [148.646, 148.646, 100], + "t": 41 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [148.94, 148.94, 100], + "t": 42 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [149.21, 149.21, 100], + "t": 43 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [149.451, 149.451, 100], + "t": 44 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [149.663, 149.663, 100], + "t": 45 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [149.846, 149.846, 100], + "t": 46 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 47 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150.126, 150.126, 100], + "t": 48 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150.226, 150.226, 100], + "t": 49 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150.301, 150.301, 100], + "t": 50 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150.355, 150.355, 100], + "t": 51 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150.39, 150.39, 100], + "t": 52 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150.408, 150.408, 100], + "t": 53 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150.412, 150.412, 100], + "t": 54 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150.404, 150.404, 100], + "t": 55 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150.387, 150.387, 100], + "t": 56 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150.362, 150.362, 100], + "t": 57 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150.333, 150.333, 100], + "t": 58 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150.299, 150.299, 100], + "t": 59 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150.264, 150.264, 100], + "t": 60 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150.229, 150.229, 100], + "t": 61 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150.193, 150.193, 100], + "t": 62 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150.159, 150.159, 100], + "t": 63 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150.127, 150.127, 100], + "t": 64 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150.097, 150.097, 100], + "t": 65 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150.07, 150.07, 100], + "t": 66 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150.046, 150.046, 100], + "t": 67 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150.025, 150.025, 100], + "t": 68 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150.008, 150.008, 100], + "t": 69 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [149.993, 149.993, 100], + "t": 70 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [149.981, 149.981, 100], + "t": 71 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [149.972, 149.972, 100], + "t": 72 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [149.965, 149.965, 100], + "t": 73 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [149.961, 149.961, 100], + "t": 74 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [149.958, 149.958, 100], + "t": 75 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [149.957, 149.957, 100], + "t": 76 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [149.957, 149.957, 100], + "t": 77 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [149.958, 149.958, 100], + "t": 78 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [149.96, 149.96, 100], + "t": 79 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [149.963, 149.963, 100], + "t": 80 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [149.967, 149.967, 100], + "t": 81 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [149.97, 149.97, 100], + "t": 82 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [149.974, 149.974, 100], + "t": 83 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [149.978, 149.978, 100], + "t": 84 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [149.981, 149.981, 100], + "t": 85 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [149.985, 149.985, 100], + "t": 86 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [149.988, 149.988, 100], + "t": 87 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [149.991, 149.991, 100], + "t": 88 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [149.994, 149.994, 100], + "t": 89 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [149.996, 149.996, 100], + "t": 90 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [149.998, 149.998, 100], + "t": 91 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 92 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150.001, 150.001, 100], + "t": 93 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150.003, 150.003, 100], + "t": 94 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150.003, 150.003, 100], + "t": 95 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150.004, 150.004, 100], + "t": 96 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150.004, 150.004, 100], + "t": 97 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150.005, 150.005, 100], + "t": 98 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150.005, 150.005, 100], + "t": 99 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150.004, 150.004, 100], + "t": 100 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150.004, 150.004, 100], + "t": 101 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150.004, 150.004, 100], + "t": 102 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150.004, 150.004, 100], + "t": 103 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150.003, 150.003, 100], + "t": 104 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150.003, 150.003, 100], + "t": 105 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150.003, 150.003, 100], + "t": 106 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150.002, 150.002, 100], + "t": 107 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150.002, 150.002, 100], + "t": 108 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150.001, 150.001, 100], + "t": 109 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150.001, 150.001, 100], + "t": 110 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150.001, 150.001, 100], + "t": 111 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150.001, 150.001, 100], + "t": 112 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 113 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 114 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 115 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 116 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 117 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 118 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 119 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 120 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 121 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 122 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 123 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 124 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 125 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 126 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 127 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 128 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 129 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 130 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 131 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 132 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 133 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 134 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 135 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 136 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 137 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 138 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 139 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 140 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 141 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 142 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 143 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 144 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 145 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 146 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 147 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 148 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 149 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 150 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 151 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 152 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 153 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 154 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 155 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 156 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 157 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 158 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 159 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 160 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 161 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 162 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 163 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 164 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 165 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 166 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 167 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 168 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 169 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 170 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 171 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 172 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 173 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 174 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 175 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 176 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 177 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 178 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 179 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 180 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 181 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 182 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 183 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 184 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 185 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 186 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 187 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 188 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 189 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 190 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 191 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 192 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 193 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 194 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 195 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 196 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 197 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 198 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 199 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 200 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 201 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 202 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 203 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 204 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 205 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 206 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 207 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 208 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 209 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 210 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 211 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 212 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 213 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 214 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 215 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 216 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 217 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 218 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 219 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 220 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 221 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 222 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 223 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 224 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 225 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 226 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 227 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 228 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 229 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 230 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 231 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 232 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 233 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 234 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 235 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 236 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 237 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 238 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 239 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 240 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 241 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 242 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 243 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 244 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 245 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 246 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 247 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 248 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 249 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 250 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 251 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 252 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 253 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 254 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 255 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 256 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 257 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 258 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 259 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 260 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 261 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 262 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 263 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 264 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 265 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 266 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 267 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 268 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 269 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 270 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 271 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 272 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 273 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 274 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 275 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 276 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 277 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 278 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 279 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 280 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 281 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 282 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 283 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 284 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 285 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 286 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 287 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 288 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 289 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 290 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 291 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 292 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 293 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 294 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 295 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 296 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 297 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 298 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 299 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 300 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 301 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 302 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 303 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 304 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 305 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 306 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 307 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 308 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 309 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 310 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 311 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 312 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 313 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 314 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 315 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 316 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 317 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 318 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 319 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 320 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 321 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 322 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 323 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 324 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 325 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 326 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 327 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 328 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 329 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 330 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 331 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 332 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 333 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 334 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 335 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 336 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 337 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 338 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 339 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 340 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 341 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 342 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 343 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 344 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 345 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 346 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 347 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 348 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 349 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 350 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 351 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 352 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 353 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 354 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 355 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 356 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 357 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 358 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 359 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 360 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 361 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 362 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 363 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 364 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 365 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 366 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 367 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 368 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 369 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 370 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 371 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 372 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 373 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 374 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 375 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 376 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 377 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 378 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 379 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 380 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 381 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 382 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 383 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 384 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 385 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 386 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 387 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 388 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 389 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 390 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 391 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 392 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 393 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 394 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 395 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 396 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 397 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 398 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 399 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 400 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 401 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 402 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 403 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 404 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 405 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 406 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 407 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 408 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 409 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 410 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 411 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 412 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 413 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 414 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 415 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 416 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 417 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 418 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 419 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 420 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 421 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 422 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 423 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 424 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 425 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 426 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 427 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 428 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 429 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 430 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 431 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 432 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 433 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 434 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 435 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 436 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 437 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 438 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 439 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 440 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 441 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 442 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 443 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 444 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 445 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 446 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 447 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 448 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 449 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 450 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 451 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 452 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 453 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 454 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 455 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 456 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 457 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 458 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 459 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 460 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 461 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 462 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 463 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 464 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 465 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 466 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 467 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 468 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 469 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 470 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 471 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 472 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 473 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 474 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 475 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 476 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 477 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 478 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 479 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 480 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 481 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 482 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 483 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 484 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 485 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 486 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 487 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 488 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 489 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 490 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 491 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 492 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 493 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 494 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 495 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 496 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 497 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 498 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 499 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 500 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 501 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 502 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 503 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 504 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 505 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 506 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 507 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 508 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 509 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 510 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 511 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 512 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 513 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 514 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 515 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 516 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 517 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 518 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 519 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 520 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 521 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 522 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 523 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 524 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 525 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 526 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 527 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 528 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 529 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 530 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 531 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 532 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 533 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 534 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 535 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 536 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 537 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 538 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 539 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 540 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 541 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 542 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 543 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 544 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 545 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 546 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 547 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 548 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 549 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 550 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 551 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 552 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 553 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 554 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 555 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 556 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 557 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 558 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 559 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 560 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 561 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 562 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 563 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 564 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 565 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 566 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 567 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 568 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 569 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 570 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 571 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 572 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 573 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 574 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 575 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 576 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 577 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 578 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 579 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 580 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 581 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 582 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 583 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 584 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 585 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 586 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 587 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 588 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 589 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 590 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 591 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 592 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 593 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 594 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 595 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 596 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 597 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 598 + }, + { + "h": 1, + "o": { "x": 0.167, "y": 0.167 }, + "i": { "x": 0.833, "y": 0.833 }, + "s": [150, 150, 100], + "t": 599 + }, + { "s": [150, 150, 100], "t": 600 } + ] + }, + "p": { "a": 0, "k": [500, 500] }, + "r": { "a": 0, "k": 0 }, + "o": { "a": 0, "k": 100 } + }, + "shapes": [ + { + "ty": "gr", + "it": [ + { + "ty": "sh", + "d": 1, + "ks": { + "a": 0, + "k": { + "c": true, + "i": [ + [-110.457, 0], + [0, -110.457], + [110.457, 0], + [0, 110.457] + ], + "o": [ + [110.457, 0], + [0, 110.457], + [-110.457, 0], + [0, -110.457] + ], + "v": [ + [0, -200], + [200, 0], + [0, 200], + [-200, 0] + ] + } + } + }, + { + "ty": "st", + "lc": 1, + "lj": 1, + "ml": 4, + "o": { "a": 0, "k": 100 }, + "w": { "a": 0, "k": 0 }, + "c": { "a": 0, "k": [0.596, 0.875, 0.839] } + }, + { + "ty": "fl", + "c": { "a": 0, "k": [0.296, 0.698, 0] }, + "r": 1, + "o": { "a": 0, "k": 0 } + }, + { + "ty": "tr", + "a": { "a": 0, "k": [0, 0] }, + "s": { "a": 0, "k": [100, 100] }, + "p": { "a": 0, "k": [0, 0] }, + "r": { "a": 0, "k": 0 }, + "o": { "a": 0, "k": 100 } + } + ] + }, + { + "ty": "fl", + "c": { "a": 0, "k": [0.296, 0.698, 0] }, + "r": 1, + "o": { "a": 0, "k": 100 } + } + ], + "ind": 2 + } + ], + "v": "5.7.0", + "fr": 60, + "op": 71, + "ip": 0, + "assets": [] +} diff --git a/frontend/src/assets/suggested-apps/alby-sandbox.png b/frontend/src/assets/suggested-apps/alby-sandbox.png new file mode 100644 index 00000000..5e97681d Binary files /dev/null and b/frontend/src/assets/suggested-apps/alby-sandbox.png differ diff --git a/frontend/src/assets/suggested-apps/bitcoin-card-topup.png b/frontend/src/assets/suggested-apps/bitcoin-card-topup.png new file mode 100644 index 00000000..3ca4037d Binary files /dev/null and b/frontend/src/assets/suggested-apps/bitcoin-card-topup.png differ diff --git a/frontend/src/assets/suggested-apps/bitrequest.png b/frontend/src/assets/suggested-apps/bitrequest.png new file mode 100644 index 00000000..d4184f80 Binary files /dev/null and b/frontend/src/assets/suggested-apps/bitrequest.png differ diff --git a/frontend/src/assets/suggested-apps/castamatic.png b/frontend/src/assets/suggested-apps/castamatic.png new file mode 100644 index 00000000..c9acbfb4 Binary files /dev/null and b/frontend/src/assets/suggested-apps/castamatic.png differ diff --git a/frontend/src/assets/suggested-apps/cline.png b/frontend/src/assets/suggested-apps/cline.png new file mode 100644 index 00000000..db6f1d8f Binary files /dev/null and b/frontend/src/assets/suggested-apps/cline.png differ diff --git a/frontend/src/assets/suggested-apps/codex.png b/frontend/src/assets/suggested-apps/codex.png new file mode 100644 index 00000000..ebb9da29 Binary files /dev/null and b/frontend/src/assets/suggested-apps/codex.png differ diff --git a/frontend/src/assets/suggested-apps/cursor.png b/frontend/src/assets/suggested-apps/cursor.png new file mode 100644 index 00000000..ef49abf5 Binary files /dev/null and b/frontend/src/assets/suggested-apps/cursor.png differ diff --git a/frontend/src/assets/suggested-apps/fountain.png b/frontend/src/assets/suggested-apps/fountain.png new file mode 100644 index 00000000..7c606498 Binary files /dev/null and b/frontend/src/assets/suggested-apps/fountain.png differ diff --git a/frontend/src/assets/suggested-apps/gemini.png b/frontend/src/assets/suggested-apps/gemini.png new file mode 100644 index 00000000..64740cf9 Binary files /dev/null and b/frontend/src/assets/suggested-apps/gemini.png differ diff --git a/frontend/src/assets/suggested-apps/hermes.png b/frontend/src/assets/suggested-apps/hermes.png new file mode 100644 index 00000000..dc8a6ec6 Binary files /dev/null and b/frontend/src/assets/suggested-apps/hermes.png differ diff --git a/frontend/src/assets/suggested-apps/lnvps.png b/frontend/src/assets/suggested-apps/lnvps.png new file mode 100644 index 00000000..adeedaaa Binary files /dev/null and b/frontend/src/assets/suggested-apps/lnvps.png differ diff --git a/frontend/src/assets/suggested-apps/nadanada.png b/frontend/src/assets/suggested-apps/nadanada.png new file mode 100644 index 00000000..063f52f4 Binary files /dev/null and b/frontend/src/assets/suggested-apps/nadanada.png differ diff --git a/frontend/src/assets/suggested-apps/openclaw.png b/frontend/src/assets/suggested-apps/openclaw.png new file mode 100644 index 00000000..309a0b5f Binary files /dev/null and b/frontend/src/assets/suggested-apps/openclaw.png differ diff --git a/frontend/src/assets/suggested-apps/opencode.png b/frontend/src/assets/suggested-apps/opencode.png new file mode 100644 index 00000000..70fd01b0 Binary files /dev/null and b/frontend/src/assets/suggested-apps/opencode.png differ diff --git a/frontend/src/assets/suggested-apps/paper-scissors-hodl.png b/frontend/src/assets/suggested-apps/paper-scissors-hodl.png deleted file mode 100644 index 4fe1fd0c..00000000 Binary files a/frontend/src/assets/suggested-apps/paper-scissors-hodl.png and /dev/null differ diff --git a/frontend/src/assets/suggested-apps/payperq.png b/frontend/src/assets/suggested-apps/payperq.png new file mode 100644 index 00000000..1e3f8792 Binary files /dev/null and b/frontend/src/assets/suggested-apps/payperq.png differ diff --git a/frontend/src/assets/suggested-apps/pi.png b/frontend/src/assets/suggested-apps/pi.png new file mode 100644 index 00000000..dbf58268 Binary files /dev/null and b/frontend/src/assets/suggested-apps/pi.png differ diff --git a/frontend/src/assets/suggested-apps/sat-sorter.png b/frontend/src/assets/suggested-apps/sat-sorter.png new file mode 100644 index 00000000..172b626a Binary files /dev/null and b/frontend/src/assets/suggested-apps/sat-sorter.png differ diff --git a/frontend/src/assets/suggested-apps/satora.png b/frontend/src/assets/suggested-apps/satora.png new file mode 100644 index 00000000..f94f9ab5 Binary files /dev/null and b/frontend/src/assets/suggested-apps/satora.png differ diff --git a/frontend/src/assets/suggested-apps/satoshis-auction-house.png b/frontend/src/assets/suggested-apps/satoshis-auction-house.png deleted file mode 100644 index f132c37e..00000000 Binary files a/frontend/src/assets/suggested-apps/satoshis-auction-house.png and /dev/null differ diff --git a/frontend/src/assets/suggested-apps/sats4ai.png b/frontend/src/assets/suggested-apps/sats4ai.png new file mode 100644 index 00000000..35d3925f Binary files /dev/null and b/frontend/src/assets/suggested-apps/sats4ai.png differ diff --git a/frontend/src/assets/suggested-apps/tunnelsats.png b/frontend/src/assets/suggested-apps/tunnelsats.png new file mode 100644 index 00000000..032a2d5a Binary files /dev/null and b/frontend/src/assets/suggested-apps/tunnelsats.png differ diff --git a/frontend/src/components/AnchorReserveAlert.tsx b/frontend/src/components/AnchorReserveAlert.tsx index 4c25b6d2..0cf5983f 100644 --- a/frontend/src/components/AnchorReserveAlert.tsx +++ b/frontend/src/components/AnchorReserveAlert.tsx @@ -5,10 +5,10 @@ import { useBalances } from "src/hooks/useBalances"; import { useChannels } from "src/hooks/useChannels"; export function AnchorReserveAlert({ - amount, + amountSat, className, }: { - amount: number; + amountSat: number; className?: string; }) { const { data: balances } = useBalances(); @@ -19,9 +19,9 @@ export function AnchorReserveAlert({ } const showAlert = - amount && + amountSat && !!channels.length && - +amount > balances.onchain.spendable - channels.length * 25000; + +amountSat > balances.onchain.spendableSat - channels.length * 25000; if (!showAlert) { return null; @@ -32,12 +32,14 @@ export function AnchorReserveAlert({ Channel Anchor Reserves will be depleted - You have channels open and by spending your entire on-chain balance - including your anchor reserves may put your node at risk of unable to - reclaim funds in your channel after a force-closure. To prevent this, - set aside at least{" "} - {" "} - on-chain. +

+ You have channels open and by spending your entire on-chain balance + including your anchor reserves may put your node at risk of unable to + reclaim funds in your channel after a force-closure. To prevent this, + set aside at least{" "} + {" "} + on-chain. +

); diff --git a/frontend/src/components/AppAvatar.tsx b/frontend/src/components/AppAvatar.tsx index d6432df3..cd658007 100644 --- a/frontend/src/components/AppAvatar.tsx +++ b/frontend/src/components/AppAvatar.tsx @@ -1,9 +1,32 @@ +import claudeLogo from "src/assets/suggested-apps/claude.png"; +import clineLogo from "src/assets/suggested-apps/cline.png"; +import codexLogo from "src/assets/suggested-apps/codex.png"; +import cursorLogo from "src/assets/suggested-apps/cursor.png"; +import geminiLogo from "src/assets/suggested-apps/gemini.png"; +import gooseLogo from "src/assets/suggested-apps/goose.png"; +import hermesLogo from "src/assets/suggested-apps/hermes.png"; +import openclawLogo from "src/assets/suggested-apps/openclaw.png"; +import opencodeLogo from "src/assets/suggested-apps/opencode.png"; import { appStoreApps } from "src/components/connections/SuggestedAppData"; import UserAvatar from "src/components/UserAvatar"; import { ALBY_ACCOUNT_APP_NAME } from "src/constants"; import { cn } from "src/lib/utils"; import { App } from "src/types"; +// Lightweight logo map for agents that don't have full app store entries. +// Matches on app_store_app_id metadata set when the connection was created. +const agentLogos: Record = { + claude: claudeLogo, + goose: gooseLogo, + hermes: hermesLogo, + openclaw: openclawLogo, + cursor: cursorLogo, + codex: codexLogo, + cline: clineLogo, + gemini: geminiLogo, + opencode: opencodeLogo, +}; + type Props = { app: App; className?: string; @@ -13,13 +36,20 @@ export default function AppAvatar({ app, className }: Props) { if (app.name === ALBY_ACCOUNT_APP_NAME) { return ; } + const agentLogo = + (app?.metadata?.app_store_app_id + ? agentLogos[app.metadata.app_store_app_id] + : undefined) || + Object.entries(agentLogos).find(([key]) => + app.name.toLowerCase().includes(key) + )?.[1]; const appStoreApp = appStoreApps.find( (suggestedApp) => (app?.metadata?.app_store_app_id && suggestedApp.id === app.metadata?.app_store_app_id) || app.name.includes(suggestedApp.title) ); - const image = appStoreApp?.logo; + const image = agentLogo || appStoreApp?.logo; const gradient = app.name diff --git a/frontend/src/components/AppHeader.tsx b/frontend/src/components/AppHeader.tsx index e5da914f..75aecb6e 100644 --- a/frontend/src/components/AppHeader.tsx +++ b/frontend/src/components/AppHeader.tsx @@ -9,6 +9,7 @@ type Props = { contentRight?: React.ReactNode; breadcrumb?: boolean; addSidebarTrigger?: boolean; + pageTitle?: string; }; function AppHeader({ @@ -17,9 +18,11 @@ function AppHeader({ description = "", contentRight, addSidebarTrigger = true, + pageTitle, }: Props) { return ( <> + {pageTitle && {`${pageTitle} · Alby Hub`}}
{addSidebarTrigger && } diff --git a/frontend/src/components/AppSidebar.tsx b/frontend/src/components/AppSidebar.tsx index 652c2d5d..3157a289 100644 --- a/frontend/src/components/AppSidebar.tsx +++ b/frontend/src/components/AppSidebar.tsx @@ -1,21 +1,23 @@ import { + BotIcon, BoxIcon, - ChevronsUpDown, - CircleHelp, + ChevronsUpDownIcon, + CreditCardIcon, + CircleHelpIcon, + HandCoinsIcon, HomeIcon, - LogOut, + LogOutIcon, LucideIcon, Plug2Icon, PlugZapIcon, - Settings, - Sparkles, - SquareStack, - StarIcon, + SettingsIcon, + SparklesIcon, + SquareStackIcon, WalletIcon, } from "lucide-react"; import React from "react"; -import { Link, NavLink, useLocation, useNavigate } from "react-router-dom"; +import { Link, NavLink, useLocation, useNavigate } from "react-router"; import ExternalLink from "src/components/ExternalLink"; import { AlbyIcon } from "src/components/icons/Alby"; @@ -23,6 +25,7 @@ import { AlbyHubIcon } from "src/components/icons/AlbyHubIcon"; import { AlbyHubLogo } from "src/components/icons/AlbyHubLogo"; import { ProBadge } from "src/components/ProBadge"; import SidebarHint from "src/components/SidebarHint"; +import { Badge } from "src/components/ui/badge"; import { DropdownMenu, DropdownMenuContent, @@ -51,6 +54,10 @@ import { useInfo } from "src/hooks/useInfo"; import { deleteAuthToken } from "src/lib/auth"; import { isHttpMode } from "src/utils/isHttpMode"; +function isPathActive(pathname: string, url: string) { + return pathname === url || pathname.startsWith(`${url}/`); +} + export function AppSidebar() { const { data: albyMe } = useAlbyMe(); @@ -88,13 +95,24 @@ export function AppSidebar() { { title: "Sub-wallets", url: "/sub-wallets", - icon: SquareStack, + icon: SquareStackIcon, }, { title: "Connections", url: "/apps", icon: Plug2Icon, }, + { + title: "AI & Agents", + url: "/ai", + icon: BotIcon, + }, + { + title: "Cards", + url: "/cards", + icon: CreditCardIcon, + badge: "NEW", + }, ], navSecondary: [ ...(hasChannelManagement @@ -109,12 +127,12 @@ export function AppSidebar() { { title: "Settings", url: "/settings", - icon: Settings, + icon: SettingsIcon, }, { - title: "Review & Earn", - url: "/review-earn", - icon: StarIcon, + title: "Earn", + url: "/earn", + icon: HandCoinsIcon, }, ], }; @@ -127,7 +145,7 @@ export function AppSidebar() {
setOpenMobile(false)}> - +
@@ -142,7 +160,7 @@ export function AppSidebar() { {item.title} + {item.badge && ( + + {item.badge} + + )} @@ -200,7 +223,7 @@ export function AppSidebar() {
)} - + e.preventDefault()}> - + Upgrade to Pro @@ -265,7 +288,7 @@ export function AppSidebar() { <> - + Log out @@ -300,7 +323,7 @@ export function NavSecondary({ - + Help diff --git a/frontend/src/components/AuthCodeForm.tsx b/frontend/src/components/AuthCodeForm.tsx index 283d4055..dd476f12 100644 --- a/frontend/src/components/AuthCodeForm.tsx +++ b/frontend/src/components/AuthCodeForm.tsx @@ -1,6 +1,6 @@ import { RefreshCwIcon } from "lucide-react"; import React, { useState } from "react"; -import { useNavigate } from "react-router-dom"; +import { useNavigate } from "react-router"; import Container from "src/components/Container"; import PasswordInput from "src/components/password/PasswordInput"; import TwoColumnLayoutHeader from "src/components/TwoColumnLayoutHeader"; @@ -62,6 +62,7 @@ function AuthCodeForm({ url }: AuthCodeFormProps) {
{!hasRequestedCode && ( diff --git a/frontend/src/components/Banner.tsx b/frontend/src/components/Banner.tsx index 8499944f..474c0c86 100644 --- a/frontend/src/components/Banner.tsx +++ b/frontend/src/components/Banner.tsx @@ -1,5 +1,5 @@ import { XIcon } from "lucide-react"; -import { Link } from "react-router-dom"; +import { Link } from "react-router"; import ExternalLink from "src/components/ExternalLink"; import { useAlbyInfo } from "src/hooks/useAlbyInfo"; diff --git a/frontend/src/components/Breadcrumbs.tsx b/frontend/src/components/Breadcrumbs.tsx index 1ab01725..4a93702e 100644 --- a/frontend/src/components/Breadcrumbs.tsx +++ b/frontend/src/components/Breadcrumbs.tsx @@ -1,5 +1,5 @@ import { Fragment } from "react"; -import { Link, useMatches } from "react-router-dom"; +import { Link, useMatches } from "react-router"; import { Breadcrumb, BreadcrumbItem, diff --git a/frontend/src/components/BudgetAmountSelect.tsx b/frontend/src/components/BudgetAmountSelect.tsx index d903bd1f..2ecaa61d 100644 --- a/frontend/src/components/BudgetAmountSelect.tsx +++ b/frontend/src/components/BudgetAmountSelect.tsx @@ -2,97 +2,77 @@ import React from "react"; import { FormattedBitcoinAmount } from "src/components/FormattedBitcoinAmount"; import FormattedFiatAmount from "src/components/FormattedFiatAmount"; import { Input } from "src/components/ui/input"; -import { Label } from "src/components/ui/label"; import { cn } from "src/lib/utils"; -import { budgetOptions as defaultBudgetOptions } from "src/types"; +import { budgetOptionsSat as defaultBudgetOptionsSat } from "src/types"; function BudgetAmountSelect({ - value, + valueSat, onChange, - minAmount, - budgetOptions = defaultBudgetOptions, + minAmountSat, + budgetOptionsSat = defaultBudgetOptionsSat, }: { - value: number; + valueSat: number; onChange: (value: number) => void; - minAmount?: number; - budgetOptions?: typeof defaultBudgetOptions; + minAmountSat?: number; + budgetOptionsSat?: typeof defaultBudgetOptionsSat; }) { - const [customBudget, setCustomBudget] = React.useState( - value ? !Object.values(budgetOptions).includes(value) : false + const [inputValue, setInputValue] = React.useState( + valueSat ? String(valueSat) : "" ); + + React.useEffect(() => { + setInputValue(valueSat ? String(valueSat) : ""); + }, [valueSat]); + return ( <> -
- {Object.keys(budgetOptions) +
+ {Object.keys(budgetOptionsSat) .filter( (budget) => - !minAmount || - !budgetOptions[budget] || - budgetOptions[budget] > minAmount + !minAmountSat || budgetOptionsSat[budget] >= minAmountSat ) - .map((budget) => { - return ( - - ); - })} - + .map((budget) => ( + + ))} +
+
+ { + setInputValue(e.target.value); + const n = parseInt(e.target.value); + onChange(!isNaN(n) && n > 0 ? n : 0); + }} + />
- {customBudget && ( -
- - { - onChange(parseInt(e.target.value)); - }} - /> -
- )} ); } diff --git a/frontend/src/components/BudgetRenewalSelect.tsx b/frontend/src/components/BudgetRenewalSelect.tsx index 12842cef..d7ace8da 100644 --- a/frontend/src/components/BudgetRenewalSelect.tsx +++ b/frontend/src/components/BudgetRenewalSelect.tsx @@ -1,4 +1,3 @@ -import { XIcon } from "lucide-react"; import React from "react"; import { Label } from "src/components/ui/label"; import { @@ -13,40 +12,28 @@ import { BudgetRenewalType, validBudgetRenewals } from "src/types"; interface BudgetRenewalProps { value: BudgetRenewalType; onChange: (value: BudgetRenewalType) => void; - onClose?: () => void; } const BudgetRenewalSelect: React.FC = ({ value, onChange, - onClose, }) => { return ( - <> - -
- -
- +
+ + +
); }; diff --git a/frontend/src/components/CardButton.tsx b/frontend/src/components/CardButton.tsx deleted file mode 100644 index 25719bc4..00000000 --- a/frontend/src/components/CardButton.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import { ChevronRightIcon } from "lucide-react"; -import { ReactElement } from "react"; -import { Link } from "react-router-dom"; -import { Card } from "src/components/ui/card"; - -type Props = { - title: string | ReactElement; - description: string | ReactElement; - to: string; -}; - -function CardButton({ title, description, to }: Props) { - return ( - - -
-
-
- {title} -
-
{description}
-
-
- -
-
-
- - ); -} - -export default CardButton; diff --git a/frontend/src/components/CloseChannelDialogContent.tsx b/frontend/src/components/CloseChannelDialogContent.tsx index b5cc5bb7..91145b14 100644 --- a/frontend/src/components/CloseChannelDialogContent.tsx +++ b/frontend/src/components/CloseChannelDialogContent.tsx @@ -118,19 +118,24 @@ export function CloseChannelDialogContent({ alias, channel }: Props) {
Closing this channel will move{" "} - in - this channel to your on-chain balance and reduce your + {" "} + in this channel to your on-chain balance and reduce your receive limit by{" "} - . + + .
-

Node ID

+

Node ID

{channel.remotePubkey}

-

Channel ID

+

Channel ID

{channel.id}

@@ -177,7 +182,7 @@ export function CloseChannelDialogContent({ alias, channel }: Props) {
@@ -198,7 +203,7 @@ export function CloseChannelDialogContent({ alias, channel }: Props) {
@@ -231,7 +236,9 @@ export function CloseChannelDialogContent({ alias, channel }: Props) { Channel closed successfully -

Funding Transaction Id

+

+ Funding Transaction Id +

{fundingTxId}

runCommand(() => navigate("/home"))} keywords={["dashboard"]} > - + Home runCommand(() => navigate("/wallet"))}> - + Wallet navigate("/apps?tab=connected-apps")) } > - + Connected Apps runCommand(() => navigate("/sub-wallets"))} > - + Sub-wallets runCommand(() => navigate("/channels"))} keywords={["node", "liquidity", "channels"]} > - + Node runCommand(() => navigate("/peers"))}> - + Peers runCommand(() => navigate("/apps?tab=app-store"))} > - + App Store @@ -123,25 +123,25 @@ export function CommandPalette({ open, onOpenChange }: CommandPaletteProps) { runCommand(() => navigate("/wallet/send"))} > - + Send Payment runCommand(() => navigate("/wallet/receive"))} > - + Receive Payment runCommand(() => navigate("/wallet/swap"))} > - + Swap runCommand(() => navigate("/wallet/swap/auto"))} > - + Auto Swap navigate("/wallet/receive/invoice")) } > - + Create Invoice - runCommand(() => navigate("/wallet/receive/onchain")) + runCommand(() => navigate("/wallet/receive?type=onchain")) } > - + Receive On-chain runCommand(() => navigate("/wallet/sign-message"))} > - + Sign Message @@ -173,13 +173,13 @@ export function CommandPalette({ open, onOpenChange }: CommandPaletteProps) { onSelect={() => runCommand(() => navigate("/settings"))} keywords={["theme", "fiat", "currency", "dark"]} > - + Settings runCommand(() => navigate("/settings/backup"))} > - + Backup navigate("/settings/alby-account")) } > - + Alby Account runCommand(() => navigate("/settings/about"))} > - + About runCommand(() => navigate("/settings/developer"))} > - + Developer Settings runCommand(() => navigate("/apps/new"))}> - + Connect New App runCommand(() => navigate("/sub-wallets/new"))} > - + Create Sub-wallet runCommand(() => navigate("/channels/incoming"))} > - + Open Channel runCommand(() => navigate("/peers/new"))} > - + Connect Peer diff --git a/frontend/src/components/CryptoSwapAlert.tsx b/frontend/src/components/CryptoSwapAlert.tsx new file mode 100644 index 00000000..c734510e --- /dev/null +++ b/frontend/src/components/CryptoSwapAlert.tsx @@ -0,0 +1,32 @@ +import { CoinsIcon, ExternalLinkIcon } from "lucide-react"; +import { FixedFloatButton } from "src/components/FixedFloatButton"; +import { Alert, AlertDescription, AlertTitle } from "src/components/ui/alert"; + +type CryptoSwapAlertProps = { + className?: string; +}; +export function CryptoSwapAlert({ className }: CryptoSwapAlertProps) { + return ( + + + + Looking to pay to other Cryptocurrency? + + +

+ If you are trying to pay a non-Bitcoin payment destination, use + FixedFloat to swap and complete the payment across 70+ supported + cryptocurrencies. +

+ + Pay with FixedFloat + + +
+
+ ); +} diff --git a/frontend/src/components/CurrencyInputField.tsx b/frontend/src/components/CurrencyInputField.tsx new file mode 100644 index 00000000..33014d91 --- /dev/null +++ b/frontend/src/components/CurrencyInputField.tsx @@ -0,0 +1,528 @@ +import * as React from "react"; +import { toast } from "sonner"; +import { + Field, + FieldDescription, + FieldError, + FieldLabel, +} from "src/components/ui/field"; +import { + InputGroup, + InputGroupAddon, + InputGroupButton, + InputGroupInput, +} from "src/components/ui/input-group"; +import { Skeleton } from "src/components/ui/skeleton"; +import { BITCOIN_DISPLAY_FORMAT_BIP177 } from "src/constants"; +import { useBitcoinRate } from "src/hooks/useBitcoinRate"; +import { useInfo } from "src/hooks/useInfo"; +import { cn } from "src/lib/utils"; + +type CurrencyInputMode = "bitcoin" | "fiat"; +type BitcoinDenomination = "sats" | "btc"; + +export type CurrencyInputContextRow = { + label: string; + amountSat?: number | null; + value?: React.ReactNode; +}; + +type CurrencyInputFieldProps = Omit< + React.ComponentProps, + "max" | "min" | "onChange" | "step" | "type" | "value" +> & { + contextRows?: CurrencyInputContextRow[]; + description?: React.ReactNode; + error?: React.ReactNode; + label?: React.ReactNode; + maxSat?: number; + minSat?: number; + onValueSatChange: (valueSat: string) => void; + valueSat: string; +}; + +const SATS_PER_BTC = 100_000_000; + +function getNumericValue(value: string | number | null | undefined) { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : 0; +} + +function getCurrencyFractionDigits(currency: string) { + try { + return new Intl.NumberFormat("en-US", { + currency, + style: "currency", + }).resolvedOptions().maximumFractionDigits; + } catch { + return 2; + } +} + +function getCurrencySymbol(currency: string) { + try { + return ( + new Intl.NumberFormat("en-US", { + currency, + style: "currency", + }) + .formatToParts(0) + .find((part) => part.type === "currency")?.value || currency + ); + } catch { + return currency; + } +} + +function formatFiatValue( + amountSat: string | number | undefined, + rate: number | undefined, + currency: string | undefined +) { + if (!rate || !currency) { + return null; + } + + return new Intl.NumberFormat("en-US", { + currency, + style: "currency", + }).format((getNumericValue(amountSat) / SATS_PER_BTC) * rate); +} + +function formatFiatInput(amountSat: string, rate: number, currency: string) { + const fractionDigits = getCurrencyFractionDigits(currency); + const amountFiat = (getNumericValue(amountSat) / SATS_PER_BTC) * rate; + + if (!amountFiat) { + return ""; + } + + return amountFiat.toFixed(fractionDigits); +} + +function formatBitcoinValue( + amountSat: string | number | null | undefined, + displayFormat: string | undefined, + denomination: BitcoinDenomination = "sats" +) { + const { amount, unit } = formatBitcoinValueParts( + amountSat, + displayFormat, + denomination + ); + + if (unit === "₿") { + return `${unit}${amount}`; + } + + return `${amount} ${unit}`; +} + +function formatBitcoinValueParts( + amountSat: string | number | null | undefined, + displayFormat: string | undefined, + denomination: BitcoinDenomination = "sats" +) { + if (denomination === "btc") { + return { + amount: formatBtcDisplay(amountSat), + unit: "BTC", + }; + } + + const formattedAmount = new Intl.NumberFormat().format( + Math.floor(getNumericValue(amountSat)) + ); + + if (displayFormat === BITCOIN_DISPLAY_FORMAT_BIP177) { + return { + amount: formattedAmount, + unit: "₿", + }; + } + + return { + amount: formattedAmount, + unit: "sats", + }; +} + +function BitcoinValueText({ + amountSat, + denomination, + displayFormat, +}: { + amountSat: string | number | null | undefined; + denomination: BitcoinDenomination; + displayFormat: string | undefined; +}) { + const { amount, unit } = formatBitcoinValueParts( + amountSat, + displayFormat, + denomination + ); + + return ( + + {unit === "₿" && {unit}} + {amount} + {unit !== "₿" && {unit}} + + ); +} + +function formatBtcDisplay(amountSat: string | number | null | undefined) { + return (getNumericValue(amountSat) / SATS_PER_BTC).toFixed(8); +} + +function formatBtcInput(amountSat: string | number | null | undefined) { + const amount = getNumericValue(amountSat); + + if (!amount) { + return ""; + } + + return (amount / SATS_PER_BTC).toFixed(8); +} + +export function CurrencyInputField({ + className, + contextRows, + description, + disabled, + error, + id, + label = "Amount", + maxSat, + minSat, + onValueSatChange, + required, + valueSat, + ...props +}: CurrencyInputFieldProps) { + const generatedId = React.useId(); + const { data: info } = useInfo(); + const { data: bitcoinRate, error: bitcoinRateError } = useBitcoinRate( + info?.currency + ); + const [mode, setMode] = React.useState("bitcoin"); + const [fiatValue, setFiatValue] = React.useState(""); + const [bitcoinDenomination, setBitcoinDenomination] = + React.useState("sats"); + const [btcValue, setBtcValue] = React.useState(""); + + const currency = info?.currency || "USD"; + const rate = bitcoinRate?.rate_float; + const canUseFiat = currency !== "SATS" && !!rate && !bitcoinRateError; + const bitcoinUnit = + info?.bitcoinDisplayFormat === BITCOIN_DISPLAY_FORMAT_BIP177 ? "₿" : "sats"; + const invalid = + props["aria-invalid"] === true || + props["aria-invalid"] === "true" || + !!error; + const inputId = id || generatedId; + const isFiatMode = mode === "fiat"; + const isBtcDenominated = bitcoinDenomination === "btc"; + const inputValue = isFiatMode + ? fiatValue + : isBtcDenominated + ? btcValue + : valueSat; + const alternateBitcoinValue = formatBitcoinValueParts( + valueSat, + info?.bitcoinDisplayFormat, + bitcoinDenomination + ); + const alternateValue = isFiatMode + ? formatBitcoinValue( + valueSat, + info?.bitcoinDisplayFormat, + bitcoinDenomination + ) + : formatFiatValue(valueSat, rate, currency); + + React.useEffect(() => { + if (mode === "fiat" && !valueSat) { + setFiatValue(""); + } + }, [mode, valueSat]); + + React.useEffect(() => { + if (mode === "bitcoin" && isBtcDenominated && !valueSat) { + setBtcValue(""); + } + }, [isBtcDenominated, mode, valueSat]); + + function handleToggleMode() { + if (disabled) { + return; + } + + if (mode === "bitcoin") { + if (!canUseFiat) { + return; + } + + setFiatValue(formatFiatInput(valueSat, rate, currency)); + setMode("fiat"); + return; + } + + if (isBtcDenominated) { + setBtcValue(formatBtcInput(valueSat)); + } + + setMode("bitcoin"); + } + + function handleAlternateValueClick() { + if (disabled || isFiatMode || !canUseFiat) { + return; + } + + handleToggleMode(); + } + + function handleToggleBitcoinDenomination() { + if (disabled) { + return; + } + + if (isBtcDenominated) { + setBitcoinDenomination("sats"); + return; + } + + setBtcValue(formatBtcInput(valueSat)); + setBitcoinDenomination("btc"); + } + + function handleChangeMode(event: React.ChangeEvent) { + // clear any custom validity set via onInvalid so the field re-validates + // on the next submit + event.currentTarget.setCustomValidity(""); + + const nextValue = event.target.value.trim(); + + if (mode === "bitcoin") { + if (!isBtcDenominated && nextValue.includes(".")) { + setBitcoinDenomination("btc"); + setBtcValue(nextValue); + toast("Switched to BTC for decimal amount"); + + if (!nextValue) { + onValueSatChange(""); + return; + } + + const amountBtc = Number(nextValue); + if (!Number.isFinite(amountBtc)) { + onValueSatChange(""); + return; + } + + onValueSatChange( + Math.max(0, Math.round(amountBtc * SATS_PER_BTC)).toString() + ); + return; + } + + if (isBtcDenominated) { + setBtcValue(nextValue); + + if (!nextValue) { + onValueSatChange(""); + return; + } + + const amountBtc = Number(nextValue); + if (!Number.isFinite(amountBtc)) { + onValueSatChange(""); + return; + } + + onValueSatChange( + Math.max(0, Math.round(amountBtc * SATS_PER_BTC)).toString() + ); + return; + } + + onValueSatChange(nextValue); + return; + } + + setFiatValue(nextValue); + + if (!nextValue || !rate) { + onValueSatChange(""); + return; + } + + const amountFiat = Number(nextValue); + if (!Number.isFinite(amountFiat)) { + onValueSatChange(""); + return; + } + + onValueSatChange( + Math.max(0, Math.round((amountFiat / rate) * SATS_PER_BTC)).toString() + ); + } + + function getModeBound(amountSat: number | undefined) { + if (amountSat === undefined) { + return undefined; + } + + if (!isFiatMode) { + if (isBtcDenominated) { + return amountSat / SATS_PER_BTC; + } + + return amountSat; + } + + if (!rate) { + return amountSat; + } + + return ((amountSat / SATS_PER_BTC) * rate).toFixed( + getCurrencyFractionDigits(currency) + ); + } + + return ( + + {label && {label}} + + + + {isFiatMode ? ( + + {getCurrencySymbol(currency)} + + ) : ( + + {isBtcDenominated ? "BTC" : bitcoinUnit} + + )} + + + {isFiatMode ? ( + + {alternateBitcoinValue.unit === "₿" && ( + {alternateBitcoinValue.unit} + )} + + {alternateBitcoinValue.amount} + + {alternateBitcoinValue.unit !== "₿" && ( + {alternateBitcoinValue.unit} + )} + + ) : ( + + {alternateValue ?? } + + )} + + + {!!contextRows?.length && ( +
+ {contextRows.map((row) => ( +
+ {row.label}: + + {row.value ?? ( + + )} + +
+ ))} +
+ )} + {description && {description}} + {error && {error}} +
+ ); +} diff --git a/frontend/src/components/DisconnectPeerDialogContent.tsx b/frontend/src/components/DisconnectPeerDialogContent.tsx index be5e6b79..fae9e9f3 100644 --- a/frontend/src/components/DisconnectPeerDialogContent.tsx +++ b/frontend/src/components/DisconnectPeerDialogContent.tsx @@ -53,7 +53,7 @@ export function DisconnectPeerDialogContent({ peer }: Props) { Are you sure you wish to disconnect from{" "} {peerDetails?.alias || "this peer"}?

-

Peer Pubkey

+

Peer Pubkey

{peer.nodeId}

diff --git a/frontend/src/components/EmptyState.tsx b/frontend/src/components/EmptyState.tsx index 9604399c..829705b2 100644 --- a/frontend/src/components/EmptyState.tsx +++ b/frontend/src/components/EmptyState.tsx @@ -3,37 +3,44 @@ import React from "react"; import { LinkButton } from "src/components/ui/custom/link-button"; import { cn } from "src/lib/utils"; -interface Props { +type Variant = "dashed" | "muted" | "none"; + +type Props = { icon: LucideIcon; title: string; description: string; - buttonText: string; - buttonLink: string; - showButton?: boolean; - showBorder?: boolean; -} + variant?: Variant; +} & ( + | { buttonText: string; buttonLink: string } + | { buttonText?: never; buttonLink?: never } +); + +const variantClasses: Record = { + dashed: "shadow-xs border border-dashed", + muted: "bg-muted", + none: "", +}; const EmptyState: React.FC = ({ icon: Icon, title: message, description: subMessage, + variant = "muted", buttonText, buttonLink, - showButton = true, - showBorder = true, }) => { return (

{message}

{subMessage}

- {showButton && ( + {buttonText && buttonLink && ( {buttonText} diff --git a/frontend/src/components/ExecuteCustomNodeCommandDialogContent.tsx b/frontend/src/components/ExecuteCustomNodeCommandDialogContent.tsx index 9391e7a8..1d563e70 100644 --- a/frontend/src/components/ExecuteCustomNodeCommandDialogContent.tsx +++ b/frontend/src/components/ExecuteCustomNodeCommandDialogContent.tsx @@ -32,7 +32,7 @@ export function ExecuteCustomNodeCommandDialogContent({ null, 2 ); - } catch (error) { + } catch { // ignore unexpected json } @@ -60,17 +60,24 @@ export function ExecuteCustomNodeCommandDialogContent({ } } + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + executeCommand(); + }; + return ( Execute Custom Node Command -