diff --git a/.env.example b/.env.example index effe2d7b..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 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 d4906852..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.11.0 + 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 index b7d6e555..e99b0d33 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -88,6 +88,8 @@ go run cmd/http/main.go 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 @@ -188,6 +190,16 @@ Code under `frontend/platform_specific/http/` and `frontend/platform_specific/wa - 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. diff --git a/Dockerfile b/Dockerfile index 2023576a..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.25 AS builder +FROM golang:1.26.2 AS builder ARG TARGETPLATFORM ARG BUILDPLATFORM diff --git a/README.md b/README.md index 5cbfc1b0..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. @@ -207,6 +207,12 @@ 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) @@ -220,6 +226,7 @@ Can be configured via env or the UI - `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. @@ -245,6 +252,7 @@ _To configure via env, the following parameters must be provided:_ - `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 @@ -283,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`. @@ -384,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 7c264d9b..c496cc95 100644 --- a/alby/alby_oauth_service.go +++ b/alby/alby_oauth_service.go @@ -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)) } @@ -1369,6 +1376,52 @@ func (svc *albyOAuthService) requestAutoChannel(ctx context.Context, url string, }, 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) @@ -1413,5 +1466,7 @@ func getEventWhitelist() []string { // client-side events "payment_failed_details", + "debit_card_url_clicked", + "debit_card_connect", } } diff --git a/alby/models.go b/alby/models.go index 2fe5e6b3..f51a636f 100644 --- a/alby/models.go +++ b/alby/models.go @@ -32,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 { @@ -118,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"` @@ -153,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 f828a92a..fc53d8e0 100644 --- a/api/api.go +++ b/api/api.go @@ -2,8 +2,11 @@ package api import ( "context" + "crypto/tls" + "crypto/x509" "encoding/hex" "encoding/json" + "encoding/pem" "errors" "flag" "fmt" @@ -11,13 +14,16 @@ import ( "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" @@ -50,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 { @@ -126,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 { @@ -151,6 +146,28 @@ 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) @@ -756,6 +773,10 @@ 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) } @@ -834,12 +855,20 @@ func (api *api) Stop() error { return nil } -func (api *api) GetNodeConnectionInfo(ctx context.Context) (*lnclient.NodeConnectionInfo, error) { +func (api *api) GetNodeConnectionInfo(ctx context.Context) (*NodeConnectionInfo, error) { lnClient := api.svc.GetLNClient() if lnClient == nil { return nil, ErrLNClientNotStarted } - return lnClient.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 { @@ -1127,20 +1156,47 @@ func (api *api) GetSwapMnemonic() string { return api.keys.GetSwapMnemonic() } -func (api *api) GetNodeStatus(ctx context.Context) (*lnclient.NodeStatus, error) { +func (api *api) GetNodeStatus(ctx context.Context) (*NodeStatus, error) { lnClient := api.svc.GetLNClient() if lnClient == nil { return nil, ErrLNClientNotStarted } - return lnClient.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) { +func toApiNodeStatus(nodeStatus *lnclient.NodeStatus) *NodeStatus { + return &NodeStatus{ + IsReady: nodeStatus.IsReady, + InternalNodeStatus: nodeStatus.InternalNodeStatus, + } +} + +func (api *api) ListPeers(ctx context.Context) ([]PeerDetails, error) { lnClient := api.svc.GetLNClient() if lnClient == nil { return nil, ErrLNClientNotStarted } - return lnClient.ListPeers(ctx) + 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 { @@ -1148,7 +1204,11 @@ func (api *api) ConnectPeer(ctx context.Context, connectPeerRequest *ConnectPeer if lnClient == nil { return ErrLNClientNotStarted } - return lnClient.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) { @@ -1156,7 +1216,17 @@ func (api *api) OpenChannel(ctx context.Context, openChannelRequest *OpenChannel if lnClient == nil { return nil, ErrLNClientNotStarted } - return lnClient.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 { @@ -1180,11 +1250,15 @@ func (api *api) CloseChannel(ctx context.Context, peerId, channelId string, forc "channel_id": channelId, "force": force, }).Info("Closing channel") - return lnClient.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 { @@ -1195,7 +1269,13 @@ func (api *api) UpdateChannel(ctx context.Context, updateChannelRequest *UpdateC logger.Logger.WithFields(logrus.Fields{ "request": updateChannelRequest, }).Info("updating channel") - return lnClient.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) { @@ -1306,7 +1386,70 @@ func (api *api) GetBalances(ctx context.Context) (*BalancesResponse, error) { 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 @@ -1365,8 +1508,22 @@ 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", "") setupCompleted, err := api.cfg.SetupCompleted() if err != nil { @@ -1383,6 +1540,7 @@ func (api *api) GetInfo(ctx context.Context) (*InfoResponse, error) { } 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() @@ -1390,7 +1548,11 @@ func (api *api) GetInfo(ctx context.Context) (*InfoResponse, error) { 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{} @@ -1425,10 +1587,28 @@ func (api *api) GetInfo(ctx context.Context) (*InfoResponse, error) { 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() + } } } @@ -1439,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") } @@ -1453,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) } @@ -1467,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 } @@ -1561,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 @@ -1595,12 +1810,18 @@ func (api *api) Setup(ctx context.Context, setupRequest *SetupRequest) error { } } if setupRequest.LNDCertFile != "" { - certBytes, err := os.ReadFile(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 { - logger.Logger.WithError(err).Error("Failed to read lnd cert file") - return err + // 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") } - certHex := hex.EncodeToString(certBytes) err = api.cfg.SetUpdate("LNDCertHex", certHex, setupRequest.UnlockPassword) if err != nil { logger.Logger.WithError(err).Error("Failed to save lnd cert hex") @@ -1608,12 +1829,17 @@ func (api *api) Setup(ctx context.Context, setupRequest *SetupRequest) error { } } if setupRequest.LNDMacaroonFile != "" { - macaroonBytes, err := os.ReadFile(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 { - logger.Logger.WithError(err).Error("Failed to read lnd macaroon file") - return err + // 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") } - macaroonHex := hex.EncodeToString(macaroonBytes) err = api.cfg.SetUpdate("LNDMacaroonHex", macaroonHex, setupRequest.UnlockPassword) if err != nil { logger.Logger.WithError(err).Error("Failed to save lnd macaroon hex") @@ -1653,6 +1879,15 @@ func (api *api) Setup(ctx context.Context, setupRequest *SetupRequest) error { } 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") @@ -1671,6 +1906,101 @@ func (api *api) Setup(ctx context.Context, setupRequest *SetupRequest) error { 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) { lnClient := api.svc.GetLNClient() if lnClient == nil { @@ -1737,12 +2067,27 @@ func (api *api) SyncWallet() error { lnClient.UpdateLastWalletSyncRequest() return nil } -func (api *api) ListOnchainTransactions(ctx context.Context) ([]lnclient.OnchainTransaction, error) { +func (api *api) ListOnchainTransactions(ctx context.Context) ([]OnchainTransaction, error) { lnClient := api.svc.GetLNClient() if lnClient == nil { return nil, ErrLNClientNotStarted } - return lnClient.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) { @@ -1818,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) 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 9d89ef7b..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) @@ -76,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. @@ -126,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") @@ -152,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 } @@ -204,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) @@ -239,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") } } @@ -259,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) @@ -285,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, } @@ -295,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/models.go b/api/models.go index 375a9556..355e6602 100644 --- a/api/models.go +++ b/api/models.go @@ -8,7 +8,6 @@ import ( "github.com/getAlby/hub/alby" "github.com/getAlby/hub/db" - "github.com/getAlby/hub/lnclient" "github.com/getAlby/hub/swaps" ) @@ -23,14 +22,15 @@ type API interface { 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) @@ -43,10 +43,10 @@ type API interface { SignMessage(ctx context.Context, message string) (*SignMessageResponse, 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) + 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) (*MakeInvoiceResponse, 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) @@ -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) @@ -306,38 +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"` - ChainDataSourceType string `json:"chainDataSourceType,omitempty"` - ChainDataSourceAddress string `json:"chainDataSourceAddress,omitempty"` - HideUpdateBanner bool `json:"hideUpdateBanner"` + 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 { @@ -360,11 +367,68 @@ 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"` @@ -388,8 +452,45 @@ 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 @@ -399,6 +500,13 @@ 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"` @@ -488,6 +596,7 @@ type MakeInvoiceRequest struct { AmountSat *uint64 `json:"amountSat"` AmountMsat *uint64 `json:"amountMsat"` Description string `json:"description"` + ToAppID *uint `json:"toAppId"` } type ResetRouterRequest struct { @@ -502,7 +611,7 @@ type BasicRestoreWailsRequest struct { UnlockPassword string `json:"unlockPassword"` } -type NetworkGraphResponse = lnclient.NetworkGraphResponse +type NetworkGraphResponse = interface{} type LSPOrderRequest struct { Amount *uint64 `json:"amount"` // deprecated diff --git a/api/rebalance.go b/api/rebalance.go index 9afce454..2854d5b3 100644 --- a/api/rebalance.go +++ b/api/rebalance.go @@ -122,7 +122,7 @@ func (api *api) RebalanceChannel(ctx context.Context, rebalanceChannelRequest *R return nil, err } - if paymentRequest.MSatoshi > int64(float64(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") } 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 b1a530e9..eb5109b4 100644 --- a/api/transactions.go +++ b/api/transactions.go @@ -4,20 +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, amountMsat uint64, description string) (*MakeInvoiceResponse, error) { +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, amountMsat, description, "", 0, nil, lnClient, 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 } @@ -40,7 +49,47 @@ func (api *api) SetTransactionUserLabels(ctx context.Context, id uint, labels ma return api.svc.GetTransactionsService().SetTransactionUserLabels(ctx, id, labels) } -func (api *api) ListTransactions(ctx context.Context, appId *uint, limit uint64, offset uint64) (*ListTransactionsResponse, error) { +// 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 @@ -51,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, lnClient, 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)) } 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 c0bbda7c..62c53b98 100644 --- a/apps/apps_service.go +++ b/apps/apps_service.go @@ -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 ecc6a78d..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) }) } } @@ -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 57a17685..c0434665 100644 --- a/config/config.go +++ b/config/config.go @@ -408,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 84256b3d..48860104 100644 --- a/config/models.go +++ b/config/models.go @@ -6,6 +6,7 @@ const ( PhoenixBackendType = "PHOENIX" CashuBackendType = "CASHU" CLNBackendType = "CLN" + BarkBackendType = "BARK" ) const ( @@ -37,6 +38,7 @@ type AppConfig struct { 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"` @@ -63,6 +65,10 @@ type AppConfig struct { 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 { @@ -88,6 +94,7 @@ type Config interface { 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 diff --git a/config/tests/config_test.go b/config/tests/config_test.go index ae1bfd0d..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) @@ -219,7 +257,7 @@ func TestJWTSecret_GeneratedOnLoad(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.LoadJWTSecret("123") @@ -251,9 +289,6 @@ func TestJWTSecret_WrongPassword(t *testing.T) { cfg, err := config.NewConfig(&config.AppConfig{}, svc.DB) require.NoError(t, err) - err = cfg.ChangeUnlockPassword("", "123") - require.NoError(t, err) - err = cfg.SaveUnlockPasswordCheck("123") require.NoError(t, err) @@ -272,7 +307,7 @@ 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.LoadJWTSecret("123") @@ -282,7 +317,7 @@ func TestJWTSecret_ChangePassword(t *testing.T) { 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() @@ -306,7 +341,7 @@ func TestJWTSecret_ReplaceUnencryptedSecretOnLoad(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 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/frontend/package.json b/frontend/package.json index 71cc9fa7..3109831a 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -20,14 +20,13 @@ "prepare": "cd .. && husky frontend/.husky" }, "dependencies": { - "@base-ui/react": "^1.4.1", - "@fontsource-variable/figtree": "^5.2.10", - "@fontsource-variable/inter": "^5.2.8", + "@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": "^7.0.0", + "@getalby/sdk": "^8.0.3", "@scure/bip39": "^2.2.0", "@stepperize/react": "^6.1.0", - "argon2-wasm-esm": "^1.0.3", "bitcoin-address-validation": "^3.0.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", @@ -36,36 +35,35 @@ "date-fns": "^4.1.0", "dayjs": "^1.11.20", "embla-carousel-react": "^8.6.0", - "lucide-react": "^1.7.0", + "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-lottie": "^1.2.4", - "react-qr-code": "^2.0.12", - "react-router": "^7.14.2", + "react-router": "^7.18.2", "sonner": "^2.0.7", "swr": "^2.4.1", - "tailwind-merge": "^3.4.1", + "tailwind-merge": "^3.6.0", "tw-animate-css": "^1.4.0", "vaul": "^1.1.2", "zustand": "^5.0.12" }, "devDependencies": { "@commitlint/cli": "^20.5.3", - "@commitlint/config-conventional": "^20.5.0", + "@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.2.4", - "@types/node": "^25.6.2", + "@types/node": "^25.9.3", "@types/react": "^19.2.14", "@types/react-dom": "^19.0.0", - "@types/react-lottie": "^1.2.10", - "@vitejs/plugin-react-swc": "^4.3.0", - "eslint": "^10.3.0", + "@vitejs/plugin-react-swc": "^4.3.1", + "eslint": "^10.4.1", "eslint-config-prettier": "^10.1.8", "eslint-plugin-react-hooks": "^7.1.1", "eslint-plugin-react-refresh": "^0.5.2", @@ -74,12 +72,11 @@ "lint-staged": "^16.4.0", "prettier": "3.8.3", "shx": "^0.4.0", - "tailwindcss": "^4.2.2", + "tailwindcss": "^4.3.0", "typescript": "^5.9.3", - "typescript-eslint": "^8.59.2", - "vite": "^5.4.0", - "vite-plugin-pwa": "^1.2.0", - "vite-tsconfig-paths": "^6.1.1" + "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/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/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/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/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/lendaswap.png b/frontend/src/assets/suggested-apps/lendaswap.png deleted file mode 100644 index 6987c6ac..00000000 Binary files a/frontend/src/assets/suggested-apps/lendaswap.png and /dev/null 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/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/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/components/AnchorReserveAlert.tsx b/frontend/src/components/AnchorReserveAlert.tsx index a4aa1ee5..0cf5983f 100644 --- a/frontend/src/components/AnchorReserveAlert.tsx +++ b/frontend/src/components/AnchorReserveAlert.tsx @@ -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 526c1be4..cd658007 100644 --- a/frontend/src/components/AppAvatar.tsx +++ b/frontend/src/components/AppAvatar.tsx @@ -4,6 +4,7 @@ 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"; @@ -17,6 +18,7 @@ import { App } from "src/types"; const agentLogos: Record = { claude: claudeLogo, goose: gooseLogo, + hermes: hermesLogo, openclaw: openclawLogo, cursor: cursorLogo, codex: codexLogo, diff --git a/frontend/src/components/AppSidebar.tsx b/frontend/src/components/AppSidebar.tsx index 6d4eb8e8..3157a289 100644 --- a/frontend/src/components/AppSidebar.tsx +++ b/frontend/src/components/AppSidebar.tsx @@ -2,6 +2,7 @@ import { BotIcon, BoxIcon, ChevronsUpDownIcon, + CreditCardIcon, CircleHelpIcon, HandCoinsIcon, HomeIcon, @@ -105,6 +106,11 @@ export function AppSidebar() { title: "AI & Agents", url: "/ai", icon: BotIcon, + }, + { + title: "Cards", + url: "/cards", + icon: CreditCardIcon, badge: "NEW", }, ], 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/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/FirstChannelJitAlert.tsx b/frontend/src/components/FirstChannelJitAlert.tsx new file mode 100644 index 00000000..7f05ccf1 --- /dev/null +++ b/frontend/src/components/FirstChannelJitAlert.tsx @@ -0,0 +1,183 @@ +import { AlertTriangleIcon, InfoIcon } from "lucide-react"; +import React from "react"; +import { Link } from "react-router"; +import ExternalLink from "src/components/ExternalLink"; +import { FormattedBitcoinAmount } from "src/components/FormattedBitcoinAmount"; +import { Alert, AlertDescription, AlertTitle } from "src/components/ui/alert"; +import { useBalances } from "src/hooks/useBalances"; +import { useChannels } from "src/hooks/useChannels"; +import { useInfo } from "src/hooks/useInfo"; +import { CreateInvoiceRequest, Transaction } from "src/types"; +import { request } from "src/utils/request"; + +const PROBE_TIMEOUT_MS = 5000; + +export default function FirstChannelJitAlert() { + const { data: info } = useInfo(); + const { data: channels } = useChannels(); + const { data: balances } = useBalances(); + + // a JIT channel only opens when the feature is enabled AND an LSPS2 liquidity + // source is actually configured (jitChannelsEnabled alone is just a settings + // toggle and can be true on backends without an LSPS2 source). + const lsps2Source = info?.jitChannelsEnabled + ? info.jitChannelsLiquiditySource + : undefined; + + const minPaymentSizeMsat = info?.jitChannelsMinPaymentSizeMsat; + + const isJitEnabled = !!lsps2Source && !!channels; + // the user's first received payment opens the channel when they have none yet. + const isFirstChannel = isJitEnabled && channels.length === 0; + + // probe whether a JIT channel can actually be obtained by requesting an + // invoice for the minimum payment size. If it (or waiting for the minimum + // payment size) doesn't succeed within the timeout, we surface a fallback + // alert depending on whether the user already has channels. + const [probeState, setProbeState] = React.useState< + "loading" | "ok" | "failed" + >("loading"); + const deadlineRef = React.useRef(null); + // single-flight the non-idempotent probe invoice: hold the in-flight request + // so effect re-entry (e.g. StrictMode remount) reuses the same POST instead + // of creating a duplicate invoice. Reset when the probe window ends. + const probeRequestRef = React.useRef | null>( + null + ); + + React.useEffect(() => { + if (!isJitEnabled) { + deadlineRef.current = null; + probeRequestRef.current = null; + return; + } + + // start the 5s clock once when we enter JIT mode - it keeps ticking while + // we wait for the minimum payment size to become available. + if (deadlineRef.current === null) { + deadlineRef.current = Date.now() + PROBE_TIMEOUT_MS; + } + + let cancelled = false; + const remainingMs = deadlineRef.current - Date.now(); + if (remainingMs <= 0) { + setProbeState("failed"); + return; + } + + const timer = setTimeout(() => { + if (!cancelled) { + setProbeState("failed"); + } + }, remainingMs); + + // wait for the minimum payment size before requesting the probe invoice. + if (minPaymentSizeMsat) { + // reuse an already in-flight probe so a re-run doesn't issue a second POST. + const probeRequest = + probeRequestRef.current ?? + (probeRequestRef.current = request("/api/invoices", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + amountMsat: minPaymentSizeMsat, + description: "", + } as CreateInvoiceRequest), + })); + probeRequest + .then(() => { + if (!cancelled) { + clearTimeout(timer); + setProbeState("ok"); + } + }) + .catch(() => { + if (!cancelled) { + clearTimeout(timer); + setProbeState("failed"); + } + }); + } + + return () => { + cancelled = true; + clearTimeout(timer); + }; + }, [isJitEnabled, minPaymentSizeMsat]); + + if (!isJitEnabled || probeState === "loading") { + return null; + } + + if (probeState === "failed") { + // no channels yet and a JIT channel couldn't be obtained - the user has no + // receiving capacity at all and will fail to receive until a channel opens. + if (isFirstChannel) { + return ( + + + Can't receive payments yet + + You won't be able to receive payments until you{" "} + + open a channel + + . + + + ); + } + + // they already have channels but a JIT channel couldn't be obtained, so they + // can only receive up to their current capacity without opening one. Wait for + // balances so we don't claim a misleading "0" receivable amount. + if (!balances) { + return null; + } + return ( + + + + You can currently receive up to{" "} + + . If you want to receive a larger payment,{" "} + + open a channel + + . + + + ); + } + + // probe succeeded - only the first-channel case needs an informational alert. + if (!isFirstChannel) { + return null; + } + + return ( + + + First payment opens a channel + + A channel fee applies.{" "} + {!!minPaymentSizeMsat && ( + <> + Minimum payment{" "} + .{" "} + + )} + + Learn more + + + + ); +} diff --git a/frontend/src/components/FixedFloatSwapInFlow.tsx b/frontend/src/components/FixedFloatSwapInFlow.tsx index 04502a83..ff4bbd48 100644 --- a/frontend/src/components/FixedFloatSwapInFlow.tsx +++ b/frontend/src/components/FixedFloatSwapInFlow.tsx @@ -4,12 +4,12 @@ import { ExternalLinkIcon, HandCoinsIcon, } from "lucide-react"; -import TickSVG from "public/images/illustrations/tick.svg"; import { useEffect, useState } from "react"; import { FixedFloatButton } from "src/components/FixedFloatButton"; import { FormattedBitcoinAmount } from "src/components/FormattedBitcoinAmount"; import FormattedFiatAmount from "src/components/FormattedFiatAmount"; import LottieLoading from "src/components/LottieLoading"; +import LottieSuccess from "src/components/LottieSuccess"; import { Button } from "src/components/ui/button"; import { Card, @@ -77,7 +77,7 @@ export function FixedFloatSwapInFlow({ return ( - Waiting for Payment + Waiting for Payment... @@ -91,7 +91,7 @@ export function FixedFloatSwapInFlow({ />
- + - + Back to Wallet diff --git a/frontend/src/components/LottieLoading.tsx b/frontend/src/components/LottieLoading.tsx index 0d3e6c92..8de046d8 100644 --- a/frontend/src/components/LottieLoading.tsx +++ b/frontend/src/components/LottieLoading.tsx @@ -1,5 +1,4 @@ -import { useMemo } from "react"; -import Lottie from "react-lottie"; +import Lottie from "lottie-react"; import animationDataDark from "src/assets/lotties/loading-dark.json"; import animationDataLight from "src/assets/lotties/loading-light.json"; import { useTheme } from "src/components/ui/theme-provider"; @@ -7,15 +6,13 @@ import { useTheme } from "src/components/ui/theme-provider"; export default function LottieLoading({ size }: { size?: number }) { const { isDarkMode } = useTheme(); - const options = useMemo( - () => ({ - loop: true, - autoplay: true, - animationData: isDarkMode ? animationDataDark : animationDataLight, - rendererSettings: { preserveAspectRatio: "xMidYMid slice" }, - }), - [isDarkMode] + return ( + ); - - return ; } diff --git a/frontend/src/components/LottieSuccess.tsx b/frontend/src/components/LottieSuccess.tsx new file mode 100644 index 00000000..a846f938 --- /dev/null +++ b/frontend/src/components/LottieSuccess.tsx @@ -0,0 +1,16 @@ +import Lottie from "lottie-react"; +import animationData from "src/assets/lotties/success-check.json"; + +export default function LottieSuccess({ size = 288 }: { size?: number }) { + return ( +
+ +
+ ); +} diff --git a/frontend/src/components/OnchainTransactionsList.tsx b/frontend/src/components/OnchainTransactionsList.tsx index a81d087b..0d4dea5d 100644 --- a/frontend/src/components/OnchainTransactionsList.tsx +++ b/frontend/src/components/OnchainTransactionsList.tsx @@ -1,4 +1,4 @@ -import { LinkIcon } from "lucide-react"; +import { BitcoinIcon } from "lucide-react"; import EmptyState from "src/components/EmptyState"; import Loading from "src/components/Loading"; import OnchainTransactionItem from "src/components/OnchainTransactionItem"; @@ -18,12 +18,9 @@ export function OnchainTransactionsList() { return (
); diff --git a/frontend/src/components/PayLightningInvoice.tsx b/frontend/src/components/PayLightningInvoice.tsx index 374f4b6a..b9227e95 100644 --- a/frontend/src/components/PayLightningInvoice.tsx +++ b/frontend/src/components/PayLightningInvoice.tsx @@ -3,7 +3,6 @@ import { CopyIcon, ExternalLinkIcon } from "lucide-react"; import React from "react"; import { FixedFloatButton } from "src/components/FixedFloatButton"; import { FormattedBitcoinAmount } from "src/components/FormattedBitcoinAmount"; -import { LightningIcon } from "src/components/icons/Lightning"; import Loading from "src/components/Loading"; import QRCode from "src/components/QRCode"; import { Button } from "src/components/ui/button"; @@ -28,16 +27,13 @@ export function PayLightningInvoice({ invoice }: PayLightningInvoiceProps) { }; return ( -
-
+
+
-

Waiting for lightning payment...

+

Waiting for Payment...

-
- -
- -
+
+

@@ -50,23 +46,19 @@ export function PayLightningInvoice({ invoice }: PayLightningInvoiceProps) { }).format(fiatAmount)}

-
- - Pay with other Cryptocurrency + Pay with Crypto
diff --git a/frontend/src/components/PaymentFailedAlert.tsx b/frontend/src/components/PaymentFailedAlert.tsx index 9ccb4aab..16e57377 100644 --- a/frontend/src/components/PaymentFailedAlert.tsx +++ b/frontend/src/components/PaymentFailedAlert.tsx @@ -5,7 +5,7 @@ import { Alert, AlertDescription, AlertTitle } from "src/components/ui/alert"; import { ExternalLinkButton } from "src/components/ui/custom/external-link-button"; import { LoadingButton } from "src/components/ui/custom/loading-button"; import { useChannels } from "src/hooks/useChannels"; -import { request } from "src/utils/request"; +import { sendEvent } from "src/utils/sendEvent"; export function PaymentFailedAlert({ invoice, @@ -19,25 +19,12 @@ export function PaymentFailedAlert({ async function sendDetailsToAlby() { setSendingDetailsToAlby(true); - try { - await request(`/api/event`, { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ - event: "payment_failed_details", - properties: { - invoice, - errorMessage, - channels, - }, - }), - }); - toast("Thanks for improving Alby Hub."); - } catch (error) { - console.error(error); - } + await sendEvent("payment_failed_details", { + invoice, + errorMessage, + channels, + }); + toast("Thanks for improving Alby Hub."); setSendingDetailsToAlby(false); } diff --git a/frontend/src/components/QRCode.tsx b/frontend/src/components/QRCode.tsx index 6bc2af83..eb97b2cd 100644 --- a/frontend/src/components/QRCode.tsx +++ b/frontend/src/components/QRCode.tsx @@ -1,38 +1,116 @@ -import ReactQRCode from "react-qr-code"; +import { type ReactNode, useEffect, useMemo, useRef } from "react"; +import QRCodeStyling, { type Options } from "qr-code-styling"; +import { BitcoinPaymentIcon } from "src/components/icons/BitcoinPayment"; +import { LightningIcon } from "src/components/icons/Lightning"; import { cn } from "src/lib/utils"; export type Props = { value: string; size?: number; className?: string; + showAvatar?: boolean; + frameType?: "lightning" | "onchain"; + paymentType?: "lightning" | "onchain"; + centerContent?: ReactNode; - // set the level to Q if there are overlays - // Q will improve error correction (so we can add overlays covering up to 25% of the QR) - // at the price of decreased information density (meaning the QR codes "pixels" have to be - // smaller to encode the same information). - // While that isn't that much of a problem for lightning addresses (because they are usually quite short), - // for invoices that contain larger amount of data those QR codes can get "harder" to read. - // (meaning you have to aim your phone very precisely and have to wait longer for the reader - // to recognize the QR code) + // Use Q when an external overlay covers part of the QR code. level?: "Q" | undefined; }; -function QRCode({ value, size, level, className }: Props) { - // Do not use dark mode: some apps do not handle it well (e.g. Phoenix) - // const { isDarkMode } = useTheme(); - const fgColor = "#242424"; // isDarkMode ? "#FFFFFF" : "#242424"; - const bgColor = "#FFFFFF"; // isDarkMode ? "#242424" : "#FFFFFF"; +function QRCode({ + value, + size = 256, + level, + className, + showAvatar = false, + frameType, + paymentType, + centerContent, +}: Props) { + const resolvedFrameType = paymentType ?? frameType; + const hasCenterContent = Boolean(centerContent); + const containerRef = useRef(null); + const options = useMemo( + () => ({ + type: "svg", + width: size, + height: size, + data: value, + image: showAvatar ? "/icon-lightmode.svg" : undefined, + margin: 0, + qrOptions: { + errorCorrectionLevel: + level ?? (showAvatar || paymentType || hasCenterContent ? "Q" : "M"), + }, + imageOptions: { + crossOrigin: "anonymous", + hideBackgroundDots: true, + imageSize: 0.15, + margin: 4, + }, + dotsOptions: { + color: "var(--qr-foreground)", + type: "dots", + roundSize: false, + }, + cornersSquareOptions: { + color: "var(--qr-foreground)", + type: "extra-rounded", + }, + cornersDotOptions: { + color: "var(--qr-foreground)", + type: "dot", + }, + backgroundOptions: { + color: "var(--qr-background)", + }, + }), + [hasCenterContent, level, paymentType, showAvatar, size, value] + ); + + useEffect(() => { + const container = containerRef.current; + if (!container) { + return; + } + + const qrCode = new QRCodeStyling(options); + qrCode.append(container); + + return () => { + container.replaceChildren(); + }; + }, [options]); return ( -
- +
+
+
+
+ {(paymentType || centerContent) && ( +
+ {centerContent ?? + (paymentType === "lightning" ? ( + + ) : ( + + ))} +
+ )}
); } diff --git a/frontend/src/components/RebalanceChannelDialogContent.tsx b/frontend/src/components/RebalanceChannelDialogContent.tsx index 0e9f3da4..96a44efb 100644 --- a/frontend/src/components/RebalanceChannelDialogContent.tsx +++ b/frontend/src/components/RebalanceChannelDialogContent.tsx @@ -134,7 +134,7 @@ export function RebalanceChannelDialogContent({ }} />

- Fee: 0.3% + Fee: 0.5% {!!amountSat && ( <>  ( diff --git a/frontend/src/components/ReceiveToLightning.tsx b/frontend/src/components/ReceiveToLightning.tsx index 6c0b4f09..6a4b8761 100644 --- a/frontend/src/components/ReceiveToLightning.tsx +++ b/frontend/src/components/ReceiveToLightning.tsx @@ -1,10 +1,22 @@ -import { CopyIcon, LinkIcon, ReceiptTextIcon, ZapIcon } from "lucide-react"; +import { + ChevronRightIcon, + CopyIcon, + LinkIcon, + ReceiptTextIcon, + ZapIcon, +} from "lucide-react"; +import { Link } from "react-router"; +import FirstChannelJitAlert from "src/components/FirstChannelJitAlert"; import Loading from "src/components/Loading"; import QRCode from "src/components/QRCode"; +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, +} from "src/components/ui/accordion"; import { Button } from "src/components/ui/button"; -import { Card, CardContent, CardFooter } from "src/components/ui/card"; -import { LinkButton } from "src/components/ui/custom/link-button"; -import { Separator } from "src/components/ui/separator"; +import { Card, CardContent } from "src/components/ui/card"; import { useAlbyMe } from "src/hooks/useAlbyMe"; import { useInfo } from "src/hooks/useInfo"; import { copyToClipboard } from "src/lib/clipboard"; @@ -18,43 +30,87 @@ export function ReceiveToLightning() { } return ( - - - -

- {me.lightning_address} -

- - - - - - - Create Invoice - - {info.backendType === "LDK" && ( - - - Lightning Offer - - )} - - - Receive from On-chain / Other Cryptocurrency - - - +
+ + + + +
+

+ {me.lightning_address} +

+ +
+
+
+ + + + + Other ways to receive + + + +
+

Create Invoice

+

+ Request a specific amount with a one-time invoice +

+
+ + + {info.supportsBolt12 && ( + + +
+

Lightning Offer

+

+ Share a reusable payment code that never expires +

+
+ + + )} + + +
+

+ On-chain or Other Cryptocurrency +

+

+ Swap funds from on-chain bitcoin or other cryptocurrencies +

+
+ + +
+
+
+
+
+
); } diff --git a/frontend/src/components/ReceiveToOnchain.tsx b/frontend/src/components/ReceiveToOnchain.tsx index 523ab57f..4b852946 100644 --- a/frontend/src/components/ReceiveToOnchain.tsx +++ b/frontend/src/components/ReceiveToOnchain.tsx @@ -5,13 +5,13 @@ import { HandCoinsIcon, RefreshCwIcon, } from "lucide-react"; -import TickSVG from "public/images/illustrations/tick.svg"; import { useEffect, useRef, useState } from "react"; import { FixedFloatButton } from "src/components/FixedFloatButton"; import { FormattedBitcoinAmount } from "src/components/FormattedBitcoinAmount"; import FormattedFiatAmount from "src/components/FormattedFiatAmount"; import Loading from "src/components/Loading"; import LottieLoading from "src/components/LottieLoading"; +import LottieSuccess from "src/components/LottieSuccess"; import OnchainAddressDisplay from "src/components/OnchainAddressDisplay"; import QRCode from "src/components/QRCode"; import { Button } from "src/components/ui/button"; @@ -120,13 +120,13 @@ export function ReceiveToOnchain() { target="_blank" className="flex justify-center" > - +
- +
)} - + - + View on Mempool @@ -221,7 +220,7 @@ function DepositSuccess({ Transaction Received! - +

@@ -229,13 +228,13 @@ function DepositSuccess({

- + - + View on Mempool - + Back to Wallet diff --git a/frontend/src/components/Scopes.tsx b/frontend/src/components/Scopes.tsx index 7982b0bf..b325a265 100644 --- a/frontend/src/components/Scopes.tsx +++ b/frontend/src/components/Scopes.tsx @@ -19,7 +19,12 @@ import { SheetTitle, } from "src/components/ui/sheet"; import { cn } from "src/lib/utils"; -import { Scope, WalletCapabilities, scopeDescriptions } from "src/types"; +import { + READ_ONLY_SCOPES, + Scope, + WalletCapabilities, + scopeDescriptions, +} from "src/types"; const scopeGroups = ["full_access", "read_only", "isolated", "custom"] as const; type ScopeGroup = (typeof scopeGroups)[number]; @@ -65,17 +70,8 @@ const Scopes: React.FC = ({ }, [capabilities.scopes]); const readOnlyScopes: Scope[] = React.useMemo(() => { - const readOnlyScopes: Scope[] = [ - "get_balance", - "get_info", - "make_invoice", - "lookup_invoice", - "list_transactions", - "notifications", - ]; - return capabilities.scopes.filter((scope) => - readOnlyScopes.includes(scope) + READ_ONLY_SCOPES.includes(scope) ); }, [capabilities.scopes]); diff --git a/frontend/src/components/TransactionItem.tsx b/frontend/src/components/TransactionItem.tsx index dd60caa0..8ad3d5fa 100644 --- a/frontend/src/components/TransactionItem.tsx +++ b/frontend/src/components/TransactionItem.tsx @@ -277,10 +277,10 @@ function TransactionItem({ tx, transactionListKey }: Props) { {updatedAt.format("D MMMM YYYY, HH:mm")} - {tx.state != "failed" && type == "outgoing" && ( + {tx.state != "failed" && tx.feesPaidMsat > 0 && ( - {tx.feesPaidMsat > 0 && ( + {type == "outgoing" && ( <>  ( {((tx.feesPaidMsat / tx.amountMsat) * 100).toFixed(2)}%) diff --git a/frontend/src/components/TransactionsFilterDialog.tsx b/frontend/src/components/TransactionsFilterDialog.tsx new file mode 100644 index 00000000..4b72ee44 --- /dev/null +++ b/frontend/src/components/TransactionsFilterDialog.tsx @@ -0,0 +1,140 @@ +import React from "react"; +import { Button } from "src/components/ui/button"; +import { Checkbox } from "src/components/ui/checkbox"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "src/components/ui/dialog"; +import { Input } from "src/components/ui/input"; +import { Label } from "src/components/ui/label"; +import { ToggleGroup, ToggleGroupItem } from "src/components/ui/toggle-group"; +import { + defaultTransactionFilters, + type TransactionFilters, +} from "src/hooks/useTransactions"; + +const TYPE_OPTIONS: { label: string; value: string }[] = [ + { label: "All", value: "all" }, + { label: "Sent", value: "outgoing" }, + { label: "Received", value: "incoming" }, +]; + +type TransactionsFilterDialogProps = { + open: boolean; + onOpenChange: (open: boolean) => void; + filters: TransactionFilters; + onFiltersChange: (filters: TransactionFilters) => void; +}; + +export function TransactionsFilterDialog({ + open, + onOpenChange, + filters, + onFiltersChange, +}: TransactionsFilterDialogProps) { + const [searchTerm, setSearchTerm] = React.useState(""); + const [type, setType] = React.useState("all"); + const [minAmountSat, setMinAmountSat] = React.useState(""); + const [hideFailed, setHideFailed] = React.useState(false); + + React.useEffect(() => { + if (open) { + setSearchTerm(filters.searchTerm ?? ""); + setType(filters.type ?? "all"); + setMinAmountSat(filters.minAmountSat ? String(filters.minAmountSat) : ""); + setHideFailed(!!filters.hideFailed); + } + }, [open, filters]); + + function onSubmit(e: React.FormEvent) { + e.preventDefault(); + const parsedMinAmountSat = Number(minAmountSat); + onFiltersChange({ + searchTerm: searchTerm.trim() || undefined, + type: type === "incoming" || type === "outgoing" ? type : undefined, + minAmountSat: + Number.isSafeInteger(parsedMinAmountSat) && parsedMinAmountSat > 0 + ? parsedMinAmountSat + : undefined, + hideFailed, + }); + onOpenChange(false); + } + + function onReset() { + onFiltersChange({ ...defaultTransactionFilters }); + onOpenChange(false); + } + + return ( + + +
+ + Filter Transactions + + Choose which payments appear in your transaction list. + + +
+ + setSearchTerm(e.target.value)} + /> +
+
+ + value && setType(value)} + > + {TYPE_OPTIONS.map((option) => ( + + {option.label} + + ))} + +
+
+ + setMinAmountSat(e.target.value.trim())} + /> +
+
+ setHideFailed(checked === true)} + /> + +
+ + + + +
+
+
+ ); +} diff --git a/frontend/src/components/TransactionsList.tsx b/frontend/src/components/TransactionsList.tsx index 3efaa987..bf2d5ff1 100644 --- a/frontend/src/components/TransactionsList.tsx +++ b/frontend/src/components/TransactionsList.tsx @@ -1,36 +1,63 @@ -import { DrumIcon } from "lucide-react"; +import { LucideIcon, ZapIcon } from "lucide-react"; import { useRef, useState } from "react"; import { CustomPagination } from "src/components/CustomPagination"; import EmptyState from "src/components/EmptyState"; import Loading from "src/components/Loading"; import TransactionItem from "src/components/TransactionItem"; import { LIST_TRANSACTIONS_LIMIT } from "src/constants"; -import { getTransactionsUrl, useTransactions } from "src/hooks/useTransactions"; +import { + getTransactionsUrl, + hasActiveTransactionFilters, + useTransactions, +} from "src/hooks/useTransactions"; +import useTransactionFiltersStore from "src/state/TransactionFiltersStore"; type TransactionsListProps = { appId?: number; - showReceiveButton?: boolean; + emptyIcon?: LucideIcon; + emptyTitle?: string; + emptyDescription?: string; + emptyVariant?: "dashed" | "muted" | "none"; }; function TransactionsList({ appId, - showReceiveButton = true, + emptyIcon = ZapIcon, + emptyTitle = "No lightning payments yet", + emptyDescription = "Your payments will appear here as you start using your wallet.", + emptyVariant, }: TransactionsListProps) { const [page, setPage] = useState(1); + const { filters } = useTransactionFiltersStore(); + + // Reset pagination during render when the filters or app change, so no + // request is made for a page that may not exist under the new list. + const [prevListIdentity, setPrevListIdentity] = useState({ appId, filters }); + if ( + prevListIdentity.appId !== appId || + prevListIdentity.filters !== filters + ) { + setPrevListIdentity({ appId, filters }); + setPage(1); + } + const transactionListRef = useRef(null); const transactionListKey = getTransactionsUrl( appId, LIST_TRANSACTIONS_LIMIT, - page + page, + filters ); const { data: transactionData, isLoading } = useTransactions( appId, false, LIST_TRANSACTIONS_LIMIT, - page + page, + filters ); const transactions = transactionData?.transactions || []; const totalCount = transactionData?.totalCount || 0; + const hasActiveFilters = hasActiveTransactionFilters(filters); const handlePageChange = (page: number) => { setPage(page); @@ -48,13 +75,14 @@ function TransactionsList({
{!transactions.length ? ( ) : ( <> diff --git a/frontend/src/components/TransactionsListMenu.tsx b/frontend/src/components/TransactionsListMenu.tsx index 190599b4..7aa4cb4b 100644 --- a/frontend/src/components/TransactionsListMenu.tsx +++ b/frontend/src/components/TransactionsListMenu.tsx @@ -1,27 +1,46 @@ -import { DownloadIcon, EllipsisVerticalIcon } from "lucide-react"; +import { DownloadIcon, EllipsisVerticalIcon, FunnelIcon } from "lucide-react"; +import { useState } from "react"; +import { TransactionsFilterDialog } from "src/components/TransactionsFilterDialog"; import { Button } from "src/components/ui/button"; import { DropdownMenu, DropdownMenuContent, + DropdownMenuItem, DropdownMenuTrigger, } from "src/components/ui/dropdown-menu"; import { ProDropdownMenuItem } from "src/components/UpgradeDialog"; +import useTransactionFiltersStore from "src/state/TransactionFiltersStore"; import { handleExportTransactions } from "./transactions-utils"; export const TransactionsListMenu = ({ appId }: { appId?: number }) => { + const [filterDialogOpen, setFilterDialogOpen] = useState(false); + const { filters, setFilters } = useTransactionFiltersStore(); + return ( - - - - handleExportTransactions(appId)}> - - Export Transactions - - - + <> + + + + setFilterDialogOpen(true)}> + + Filter Transactions + + handleExportTransactions(appId)}> + + Export Transactions + + + + + ); }; diff --git a/frontend/src/components/WalletActionsMenu.tsx b/frontend/src/components/WalletActionsMenu.tsx index a7e264e3..54b997e8 100644 --- a/frontend/src/components/WalletActionsMenu.tsx +++ b/frontend/src/components/WalletActionsMenu.tsx @@ -4,9 +4,12 @@ import { CreditCardIcon, DownloadIcon, EllipsisVerticalIcon, + FunnelIcon, } from "lucide-react"; +import { useState } from "react"; import { Link } from "react-router"; import ExternalLink from "src/components/ExternalLink"; +import { TransactionsFilterDialog } from "src/components/TransactionsFilterDialog"; import { Button } from "src/components/ui/button"; import { DropdownMenu, @@ -16,55 +19,84 @@ import { DropdownMenuTrigger, } from "src/components/ui/dropdown-menu"; import { ProDropdownMenuItem } from "src/components/UpgradeDialog"; +import useTransactionFiltersStore from "src/state/TransactionFiltersStore"; import { handleExportTransactions } from "./transactions-utils"; export function WalletActionsMenu({ hasChannelManagement, + isOnchain, }: { hasChannelManagement: boolean; + isOnchain: boolean; }) { + const [filterDialogOpen, setFilterDialogOpen] = useState(false); + const { filters, setFilters } = useTransactionFiltersStore(); + return ( - - - -
- {hasChannelManagement && ( + <> + + + +
+ {hasChannelManagement && ( + + + + Swap + + + )} - - - Swap + + + Recurring + + + + Buy + + + {!isOnchain && } +
+ {!isOnchain && ( + <> + setFilterDialogOpen(true)}> + + Filter Transactions + + handleExportTransactions()}> + + Export Transactions + + )} - - - - Recurring - - - - - - Buy - - - -
- handleExportTransactions()}> - - Export Transactions - -
-
+ + + {!isOnchain && ( + + )} + ); } diff --git a/frontend/src/components/channels/ChannelWaitingForConfirmations.tsx b/frontend/src/components/channels/ChannelWaitingForConfirmations.tsx index 1774b0dc..bacb4279 100644 --- a/frontend/src/components/channels/ChannelWaitingForConfirmations.tsx +++ b/frontend/src/components/channels/ChannelWaitingForConfirmations.tsx @@ -49,6 +49,7 @@ export function ChannelWaitingForConfirmations({ icon={FootprintsIcon} title="Browse While You Wait" description="Feel free to leave this page or browse around Alby Hub! We'll send you an email as soon as your channel is active." + variant="dashed" buttonText="Explore Apps" buttonLink="/apps?tab=app-store" /> diff --git a/frontend/src/components/connections/AppStoreDetailHeader.tsx b/frontend/src/components/connections/AppStoreDetailHeader.tsx index b40c5bd2..02f4a688 100644 --- a/frontend/src/components/connections/AppStoreDetailHeader.tsx +++ b/frontend/src/components/connections/AppStoreDetailHeader.tsx @@ -24,36 +24,27 @@ export function AppStoreDetailHeader({ <> -
- -
-
- {appStoreApp.title} - {!!connectedApps.length && ( - - {" "} - {connectedApps.length > 1 - ? `${connectedApps.length} Connections` - : "Connected"} - - )} -
-
- {appStoreApp.description} -
-
-
- + icon={ + logo } - description="" + title={ +
+ {appStoreApp.title} + {!!connectedApps.length && ( + + {" "} + {connectedApps.length > 1 + ? `${connectedApps.length} Connections` + : "Connected"} + + )} +
+ } + description={appStoreApp.description} contentRight={ contentRight !== undefined ? ( contentRight diff --git a/frontend/src/components/connections/AppTransactionList.tsx b/frontend/src/components/connections/AppTransactionList.tsx index 53c6f435..bd9380a1 100644 --- a/frontend/src/components/connections/AppTransactionList.tsx +++ b/frontend/src/components/connections/AppTransactionList.tsx @@ -1,3 +1,4 @@ +import { ArrowDownUpIcon } from "lucide-react"; import TransactionsList from "src/components/TransactionsList"; import { TransactionsListMenu } from "src/components/TransactionsListMenu"; import { @@ -15,7 +16,13 @@ export function AppTransactionList({ appId }: { appId: number }) { - + ); diff --git a/frontend/src/components/connections/BitcoinCardTopupInstallGuide.tsx b/frontend/src/components/connections/BitcoinCardTopupInstallGuide.tsx new file mode 100644 index 00000000..34e65d3f --- /dev/null +++ b/frontend/src/components/connections/BitcoinCardTopupInstallGuide.tsx @@ -0,0 +1,33 @@ +import { useLocation } from "react-router"; +import ExternalLink from "src/components/ExternalLink"; + +// The card topup app supports configuration presets selected via a `provider` +// query param (e.g. linked from the Cards page). Read it straight from the URL +// so the link opens card.albylabs.com pre-configured for the chosen provider. +export function BitcoinCardTopupInstallGuide() { + const provider = new URLSearchParams(useLocation().search).get("provider"); + const url = provider + ? `https://card.albylabs.com?provider=${encodeURIComponent(provider)}` + : "https://card.albylabs.com"; + + return ( +
+
    +
  • + Open{" "} + + card.albylabs.com + {" "} + on the device you'll top up from. +
  • +
  • + + Add it to your home screen + {" "} + (or bookmark it) so you can reopen it later. +
  • +
  • Enter your card's deposit details to set it up.
  • +
+
+ ); +} diff --git a/frontend/src/components/connections/ConnectedApps.tsx b/frontend/src/components/connections/ConnectedApps.tsx index a67164c2..8e2f0e55 100644 --- a/frontend/src/components/connections/ConnectedApps.tsx +++ b/frontend/src/components/connections/ConnectedApps.tsx @@ -88,6 +88,7 @@ function ConnectedApps() { icon={CableIcon} title="Connect Your First App" description="Connect your app of choice, fine-tune permissions and enjoy a seamless and secure wallet experience." + variant="dashed" buttonText="See Recommended Apps" buttonLink="/apps?tab=app-store" /> diff --git a/frontend/src/components/connections/SuggestedAppData.tsx b/frontend/src/components/connections/SuggestedAppData.tsx index 36348acc..3647a71e 100644 --- a/frontend/src/components/connections/SuggestedAppData.tsx +++ b/frontend/src/components/connections/SuggestedAppData.tsx @@ -6,6 +6,7 @@ import albyGo from "src/assets/suggested-apps/alby-go.png"; import albySandbox from "src/assets/suggested-apps/alby-sandbox.png"; import albyCli from "src/assets/suggested-apps/alby.png"; import amethyst from "src/assets/suggested-apps/amethyst.png"; +import bitcoinCardTopup from "src/assets/suggested-apps/bitcoin-card-topup.png"; import bitrefill from "src/assets/suggested-apps/bitrefill.png"; import bitrequest from "src/assets/suggested-apps/bitrequest.png"; import bringin from "src/assets/suggested-apps/bringin.png"; @@ -19,7 +20,6 @@ import fountain from "src/assets/suggested-apps/fountain.png"; import hablanews from "src/assets/suggested-apps/habla-news.png"; import iris from "src/assets/suggested-apps/iris.png"; import jumble from "src/assets/suggested-apps/jumble.png"; -import lendaswap from "src/assets/suggested-apps/lendaswap.png"; import lightningMessageboard from "src/assets/suggested-apps/lightning-messageboard.png"; import lnbits from "src/assets/suggested-apps/lnbits.png"; import lnvps from "src/assets/suggested-apps/lnvps.png"; @@ -30,14 +30,13 @@ import nostrcheckserver from "src/assets/suggested-apps/nostrcheck-server.png"; import nostrudel from "src/assets/suggested-apps/nostrudel.png"; import nostter from "src/assets/suggested-apps/nostter.png"; import nostur from "src/assets/suggested-apps/nostur.png"; -import paperScissorsHodl from "src/assets/suggested-apps/paper-scissors-hodl.png"; import payperq from "src/assets/suggested-apps/payperq.png"; import primal from "src/assets/suggested-apps/primal.png"; import pullthatupjamie from "src/assets/suggested-apps/pullthatupjamie.png"; import runstr from "src/assets/suggested-apps/runstr.png"; -import sats4ai from "src/assets/suggested-apps/sats4ai.png"; import satsorter from "src/assets/suggested-apps/sat-sorter.png"; -import satoshisauctionhouse from "src/assets/suggested-apps/satoshis-auction-house.png"; +import satoraLogo from "src/assets/suggested-apps/satora.png"; +import sats4ai from "src/assets/suggested-apps/sats4ai.png"; import simpleboost from "src/assets/suggested-apps/simple-boost.png"; import snort from "src/assets/suggested-apps/snort.png"; import stackernews from "src/assets/suggested-apps/stacker-news.png"; @@ -54,12 +53,14 @@ import zapplepay from "src/assets/suggested-apps/zapple-pay.png"; import zappybird from "src/assets/suggested-apps/zappy-bird.png"; import zapstore from "src/assets/suggested-apps/zapstore.png"; import zeus from "src/assets/suggested-apps/zeus.png"; +import { BitcoinCardTopupInstallGuide } from "src/components/connections/BitcoinCardTopupInstallGuide"; import ExternalLink from "src/components/ExternalLink"; import { App } from "src/types"; export type AppStoreApp = { id: string; title: string; + legacyTitles?: string[]; description: string; extendedDescription: string; @@ -83,6 +84,9 @@ export type AppStoreApp = { hideConnectionQr?: boolean; internal?: boolean; superuser?: boolean; + // Receive-only apps (e.g. merchant payment receivers) default to read-only + // permissions in the new connection flow. + readonly?: boolean; addedDate?: string; }; @@ -219,6 +223,33 @@ export const appStoreApps: AppStoreApp[] = ( categories: ["audio"], addedDate: "2026-03-12", }, + { + id: "bitcoin-card-topup", + title: "Bitcoin Card Topup", + description: "Top up any crypto debit card instantly with bitcoin", + logo: bitcoinCardTopup, + categories: ["payment-tools"], + extendedDescription: + "A generic top-up app that swaps Lightning sats to a stablecoin and sends them to your card's deposit address. Works with RedotPay, Freedomia, Nexo, Bybit, and any other card that accepts on-chain crypto deposits.", + webLink: "https://card.albylabs.com", + installGuide: , + finalizeGuide: ( + <> +
+
    +
  • Copy the connection secret below.
  • +
  • + In the topup app, tap{" "} + + Connect Wallet + {" "} + and paste the connection secret. +
  • +
+
+ + ), + }, { id: "2fiat", title: "2fiat Top up", @@ -768,6 +799,7 @@ export const appStoreApps: AppStoreApp[] = ( }, { id: "sat-sorter", + readonly: true, title: "Sat Sorter", description: "A Bitcoin Budgeting App", webLink: "https://satsorter.com", @@ -867,6 +899,7 @@ export const appStoreApps: AppStoreApp[] = ( }, { id: "bitrequest", + readonly: true, title: "Bitrequest", description: "Non-custodial payment requests", webLink: "https://www.bitrequest.io", @@ -889,17 +922,6 @@ export const appStoreApps: AppStoreApp[] = ( {" "} in your browser, or download the app on iOS or Android

-

- In the next step, set wallet permissions to{" "} - Custom and - enable: -

-
    -
  • Read your node info
  • -
  • Create invoices
  • -
  • Lookup status of invoices
  • -
  • Read transaction history
  • -
), @@ -939,6 +961,7 @@ export const appStoreApps: AppStoreApp[] = ( }, { id: "btcpay", + readonly: true, title: "BTCPay Server", description: "Bitcoin payment processor", webLink: "https://btcpayserver.org/", @@ -1245,8 +1268,7 @@ export const appStoreApps: AppStoreApp[] = ( title: "wavecard® by wave.space", description: "Spend Bitcoin from your AlbyHub at 150M+ merchants worldwide", - webLink: - "https://app.wave.space/spend/?utm_source=albyhub&affiliate=AlbyHub", + webLink: "https://app.wave.space/?utm_source=albyhub&affiliate=AlbyHub", logo: wavespace, extendedDescription: "The world's first Bitcoin VISA Debit Card that allows you to spend BTC globally, anywhere VISA is accepted – straight from the safety of your own NWC-enabled wallet. ✨ EXCLUSIVE ALBYHUB SPECIAL🐝 → Get 21% cashback on your wavecard transactions (up to 10,000 sats) using code »AlbyHub«", @@ -1258,10 +1280,10 @@ export const appStoreApps: AppStoreApp[] = (
  • Open{" "} - wave.space/spend + wave.space {" "} in your browser and{" "} @@ -1427,6 +1449,7 @@ export const appStoreApps: AppStoreApp[] = ( }, { id: "clams", + readonly: true, title: "Clams", description: "Multi wallet accounting tool", webLink: "https://clams.tech/", @@ -1463,6 +1486,7 @@ export const appStoreApps: AppStoreApp[] = ( }, { id: "nostrcheck-server", + readonly: true, title: "Nostrcheck Server", description: "Sovereign Nostr services", webLink: "https://github.com/quentintaranpino/nostrcheck-server", @@ -1770,6 +1794,7 @@ export const appStoreApps: AppStoreApp[] = ( }, { id: "nakapay", + readonly: true, title: "NakaPay", description: "Non-custodial Lightning payments for businesses via NWC", webLink: "https://www.nakapay.app", @@ -2042,47 +2067,6 @@ export const appStoreApps: AppStoreApp[] = ( ), categories: ["social-media"], }, - { - id: "paper-scissors-hodl", - title: "Paper Scissors HODL", - description: "Paper Scissors Rock with bitcoin at stake", - webLink: "https://paper-scissors-hodl.fly.dev", - logo: paperScissorsHodl, - extendedDescription: - "Uses your Hub to pay to play a round, and receive the reward if you win", - installGuide: ( - <> -

    - Open{" "} - - Paper Scissors HODL - {" "} - in your browser -

    - - ), - finalizeGuide: ( - <> -
    -

    In Paper Scissors HODL

    -
      -
    • Start playing until the Bitcoin Connect screen pops up
    • -
    • - Choose{" "} - - Nostr Wallet Connect - -
    • -
    • Paste the connection secret from Alby Hub
    • -
    -
    - - ), - categories: ["games"], - }, { id: "pullthatupjamie-ai", title: "Pull That Up Jamie!", @@ -2220,10 +2204,40 @@ export const appStoreApps: AppStoreApp[] = ( title: "Bitrefill", description: "Live on bitcoin", extendedDescription: "Buy gift cards and e-sims with no KYC", - internal: true, webLink: "https://bitrefill.com", logo: bitrefill, categories: ["shopping"], + appleLink: + "https://apps.apple.com/us/app/bitrefill-esims-gift-cards/id1378102623", + playLink: + "https://play.google.com/store/apps/details?id=com.bitrefill.app&hl=en", + installGuide: ( + <> +

    + Open{" "} + + Bitrefill + {" "} + in your browser, or download the app on iOS or Android +

    + + ), + finalizeGuide: ( + <> +
    +

    In Bitrefill

    +
      +
    • + Go to settings {"->"} wallets {"->"} lightning {"->"} payments. +
    • +
    • Paste the connection secret to connect Alby Hub
    • +
    +
    + + ), }, { id: "bringin", @@ -2319,49 +2333,9 @@ export const appStoreApps: AppStoreApp[] = ( ), categories: ["social-media"], }, - { - id: "satoshis-auction-house", - title: "Satoshi's Auction House", - description: "Bitcoin-powered auction platform", - webLink: "https://satoshisauction.house", - logo: satoshisauctionhouse, - extendedDescription: - "Buy and sell items through Bitcoin-powered auctions directly from your Hub", - installGuide: ( - <> -

    - Open{" "} - - Satoshi's Auction House - {" "} - in your browser -

    - - ), - finalizeGuide: ( - <> -
    -

    In Satoshi's Auction House

    -
      -
    • - Click on the Hamburger menu on the top right and click{" "} - Settings -
    • -
    • - Paste the connection secret from Alby Hub into the receive-only - connection secret field -
    • -
    -
    - - ), - categories: ["shopping"], - }, { id: "takemysats", + readonly: true, title: "Take My Sats", description: "Create your online store and accept Bitcoin payments", webLink: "https://www.takemysats.com", @@ -2467,22 +2441,24 @@ export const appStoreApps: AppStoreApp[] = ( categories: ["misc"], }, { + // Keep the legacy app store ID so existing LendaSwap connections still match this entry. id: "lendaswap", - title: "LendaSwap", + title: "Satora", + legacyTitles: ["LendaSwap"], description: "Self-custodial Bitcoin ↔ Stablecoin atomic swaps", - webLink: "https://lendaswap.com/?ref=lnds_e3f8dd88_f7db93dbf176", - logo: lendaswap, + webLink: "https://app.satora.io/?ref=lnds_e3f8dd88_f7db93dbf176", + logo: satoraLogo, extendedDescription: - "Swap between Lightning Bitcoin and stablecoins (USDC, USDT) on Polygon, Arbitrum, and Ethereum. LendaSwap uses your Hub to pay swap invoices and generate receiving invoices — all self-custodial via HTLCs.", + "Swap between Lightning Bitcoin and stablecoins (USDC, USDT) on Polygon, Arbitrum, and Ethereum. Satora uses your Hub to pay swap invoices and generate receiving invoices — all self-custodial via HTLCs.", installGuide: ( <>

    Open{" "} - LendaSwap + Satora {" "} in your browser

    @@ -2491,7 +2467,7 @@ export const appStoreApps: AppStoreApp[] = ( finalizeGuide: ( <>
    -

    In LendaSwap

    +

    In Satora

    • Click the{" "} @@ -2533,6 +2509,7 @@ export const getAppStoreApp = (app: App) => { return appStoreApps.find( (suggestedApp) => suggestedApp.id === (app.metadata?.app_store_app_id ?? "") || - app.name.includes(suggestedApp.title) + app.name.includes(suggestedApp.title) || + suggestedApp.legacyTitles?.some((title) => app.name.includes(title)) ); }; diff --git a/frontend/src/components/connections/SuggestedApps.tsx b/frontend/src/components/connections/SuggestedApps.tsx index 239c4020..a097c081 100644 --- a/frontend/src/components/connections/SuggestedApps.tsx +++ b/frontend/src/components/connections/SuggestedApps.tsx @@ -28,7 +28,12 @@ function AppCard(app: AppStoreApp) { />
      {app.title} - {app.description} + + {app.description} + {app.legacyTitles?.length + ? ` (Previously ${app.legacyTitles.join(", ")})` + : ""} +
    diff --git a/frontend/src/components/home/widgets/AlbyAccountWidget.tsx b/frontend/src/components/home/widgets/AlbyAccountWidget.tsx deleted file mode 100644 index 8cb0feda..00000000 --- a/frontend/src/components/home/widgets/AlbyAccountWidget.tsx +++ /dev/null @@ -1,51 +0,0 @@ -import { ExternalLinkIcon } from "lucide-react"; -import { AlbyHead } from "src/components/images/AlbyHead"; -import { - Card, - CardDescription, - CardFooter, - CardHeader, - CardTitle, -} from "src/components/ui/card"; -import { ExternalLinkButton } from "src/components/ui/custom/external-link-button"; -import { useInfo } from "src/hooks/useInfo"; - -export function AlbyAccountWidget() { - const { data: info } = useInfo(); - - if (!info || !info.albyAccountConnected) { - return null; - } - - return ( - - -
    -
    - -
    -
    - -
    - Alby Account -
    -
    - - Get an Alby Account with a web wallet interface, lightning address - and other features. - -
    -
    -
    - - - Open Alby Account - - - -
    - ); -} diff --git a/frontend/src/components/home/widgets/AlbyExtensionWidget.tsx b/frontend/src/components/home/widgets/AlbyExtensionWidget.tsx deleted file mode 100644 index 9256ce6f..00000000 --- a/frontend/src/components/home/widgets/AlbyExtensionWidget.tsx +++ /dev/null @@ -1,50 +0,0 @@ -import { ExternalLinkIcon } from "lucide-react"; -import { AlbyHead } from "src/components/images/AlbyHead"; -import { - Card, - CardDescription, - CardFooter, - CardHeader, - CardTitle, -} from "src/components/ui/card"; -import { ExternalLinkButton } from "src/components/ui/custom/external-link-button"; - -export function AlbyExtensionWidget() { - /* eslint-disable @typescript-eslint/no-explicit-any */ - const extensionInstalled = (window as any).alby !== undefined; - - if (extensionInstalled) { - return null; - } - - return ( - - -
    -
    - -
    -
    - -
    - Alby Browser Extension -
    -
    - - Seamless bitcoin payments in your favorite internet browser. - -
    -
    -
    - - - Install Alby Extension - - - -
    - ); -} diff --git a/frontend/src/components/home/widgets/AlbyGoWidget.tsx b/frontend/src/components/home/widgets/AlbyGoWidget.tsx deleted file mode 100644 index 9aabc2be..00000000 --- a/frontend/src/components/home/widgets/AlbyGoWidget.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import albyGo from "src/assets/suggested-apps/alby-go.png"; -import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from "src/components/ui/card"; -import { LinkButton } from "src/components/ui/custom/link-button"; - -export function AlbyGoWidget() { - return ( - - -
    -
    - Alby Go -
    -
    - -
    - Alby Go -
    -
    - - The easiest Bitcoin mobile app that works great with Alby Hub. - -
    -
    -
    - - - Open - - -
    - ); -} diff --git a/frontend/src/components/home/widgets/StoriesWidget.tsx b/frontend/src/components/home/widgets/StoriesWidget.tsx new file mode 100644 index 00000000..c1c7b466 --- /dev/null +++ b/frontend/src/components/home/widgets/StoriesWidget.tsx @@ -0,0 +1,254 @@ +import { XIcon } from "lucide-react"; +import React from "react"; +import { Link } from "react-router"; +import useSWR from "swr"; +import ExternalLink from "src/components/ExternalLink"; +import { Button } from "src/components/ui/button"; +import { + Card, + CardContent, + CardHeader, + CardTitle, +} from "src/components/ui/card"; +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogTitle, +} from "src/components/ui/dialog"; +import { localStorageKeys } from "src/constants"; +import { cn } from "src/lib/utils"; +import { swrFetcher } from "src/utils/swr"; + +type StoryCta = { + label: string; + url: string; + openInNewTab: boolean; +}; + +type Story = { + id: string; + title: string; + avatar: string; + videoId?: string; + cta?: StoryCta; +}; + +type StoryApiResponse = { + id: number; + title: string; + avatar: string; + videoId?: string; + cta?: StoryCta; +}; + +function youTubeEmbedUrl(videoId: string) { + return `https://www.youtube-nocookie.com/embed/${videoId}?autoplay=1&rel=0`; +} + +function loadViewedStoryIds(): Set { + try { + const raw = localStorage.getItem(localStorageKeys.homeStoriesViewed); + if (!raw) { + return new Set(); + } + const parsed = JSON.parse(raw) as unknown; + if (!Array.isArray(parsed)) { + return new Set(); + } + return new Set(parsed.filter((id): id is string => typeof id === "string")); + } catch { + return new Set(); + } +} + +function persistViewedStoryIds(ids: Set) { + try { + localStorage.setItem( + localStorageKeys.homeStoriesViewed, + JSON.stringify([...ids]) + ); + } catch { + // ignore quota / private mode + } +} + +function StoryAvatar({ story, viewed }: { story: Story; viewed: boolean }) { + return ( +
    +
    + {`${story.title} +
    +
    + ); +} + +export function StoriesWidget() { + const { data, error, isLoading } = useSWR( + "/api/alby/stories", + swrFetcher + ); + const [activeStory, setActiveStory] = React.useState(null); + const [viewedIds, setViewedIds] = + React.useState>(loadViewedStoryIds); + + const stories = React.useMemo( + () => + error || !data + ? [] + : data.map((story) => ({ + id: String(story.id), + title: story.title, + avatar: story.avatar, + videoId: story.videoId, + cta: story.cta, + })), + [data, error] + ); + + const markStoryViewed = React.useCallback((storyId: string) => { + setViewedIds((prev) => { + if (prev.has(storyId)) { + return prev; + } + const next = new Set(prev); + next.add(storyId); + persistViewedStoryIds(next); + return next; + }); + }, []); + + if (!isLoading && stories.length === 0) { + return null; + } + + return ( + <> + + + Stories + + +
    + {isLoading && ( + + Loading stories... + + )} + {!isLoading && + stories.map((story) => { + const viewed = viewedIds.has(story.id); + return ( + + ); + })} +
    +
    +
    + + !open && setActiveStory(null)} + > + + {activeStory && ( +
    + {activeStory.title} + + Watch the latest update + + + {activeStory.videoId && ( +
    + -
    - - - - Confirm payment - -
    -
    -
    Description
    -
    {invoice?.description}
    -
    -
    -
    Amount
    -
    - - - - -
    -
    -
    - - Cancel - - {loading && } - Pay now - - -
    -
    - - ); -} diff --git a/frontend/src/screens/internal-apps/BuzzPay.tsx b/frontend/src/screens/internal-apps/BuzzPay.tsx index 70591100..8168fe04 100644 --- a/frontend/src/screens/internal-apps/BuzzPay.tsx +++ b/frontend/src/screens/internal-apps/BuzzPay.tsx @@ -1,7 +1,6 @@ import { AlertTriangleIcon, CopyIcon, ExternalLinkIcon } from "lucide-react"; import React from "react"; import { toast } from "sonner"; -import buzzpay from "src/assets/suggested-apps/buzzpay.png"; import { AppDetailConnectedApps } from "src/components/connections/AppDetailConnectedApps"; import { AppStoreDetailHeader } from "src/components/connections/AppStoreDetailHeader"; import { appStoreApps } from "src/components/connections/SuggestedAppData"; @@ -82,20 +81,24 @@ export function BuzzPay() { -
    +
    -
    -
    +
    - - diff --git a/frontend/src/screens/onchain/DepositBitcoin.tsx b/frontend/src/screens/onchain/DepositBitcoin.tsx index 75b95405..dfc836ce 100644 --- a/frontend/src/screens/onchain/DepositBitcoin.tsx +++ b/frontend/src/screens/onchain/DepositBitcoin.tsx @@ -1,5 +1,4 @@ import { - CircleCheckIcon, CopyIcon, CreditCardIcon, ExternalLinkIcon, @@ -12,6 +11,7 @@ import { FormattedBitcoinAmount } from "src/components/FormattedBitcoinAmount"; import FormattedFiatAmount from "src/components/FormattedFiatAmount"; import Loading from "src/components/Loading"; import LottieLoading from "src/components/LottieLoading"; +import LottieSuccess from "src/components/LottieSuccess"; import { MempoolAlert } from "src/components/MempoolAlert"; import OnchainAddressDisplay from "src/components/OnchainAddressDisplay"; import QRCode from "src/components/QRCode"; @@ -129,7 +129,7 @@ export default function DepositBitcoin() { target="_blank" className="flex justify-center" > - +
    @@ -137,11 +137,11 @@ export default function DepositBitcoin() {
    -
    +
    {!loadingAddress && } @@ -149,7 +149,7 @@ export default function DepositBitcoin() {
    @@ -201,14 +201,14 @@ function DepositPending({
    )} -
    +
    View on Mempool - +
    @@ -232,21 +232,21 @@ function DepositSuccess({ Payment Received! - +

    -
    +
    View on Mempool - +
    diff --git a/frontend/src/screens/peers/ConnectPeer.tsx b/frontend/src/screens/peers/ConnectPeer.tsx index b6c1583c..601832ea 100644 --- a/frontend/src/screens/peers/ConnectPeer.tsx +++ b/frontend/src/screens/peers/ConnectPeer.tsx @@ -9,6 +9,7 @@ import { Label } from "src/components/ui/label"; import { splitSocketAddress } from "src/lib/utils"; import { ConnectPeerRequest } from "src/types"; import { request } from "src/utils/request"; +import { safeReturnToUrl } from "src/utils/safeReturnToUrl"; export default function ConnectPeer() { const navigate = useNavigate(); @@ -18,7 +19,7 @@ export default function ConnectPeer() { const [connectionString, setConnectionString] = React.useState( queryParams.get("peer") ?? "" ); - const returnTo = queryParams.get("return_to") ?? ""; + const returnTo = safeReturnToUrl(queryParams.get("return_to")) ?? ""; const handleSubmit = async (event: React.FormEvent) => { event.preventDefault(); diff --git a/frontend/src/screens/settings/About.tsx b/frontend/src/screens/settings/About.tsx index 161958c4..b2e254ba 100644 --- a/frontend/src/screens/settings/About.tsx +++ b/frontend/src/screens/settings/About.tsx @@ -1,13 +1,31 @@ +import { ExternalLinkIcon } from "lucide-react"; +import ExternalLink from "src/components/ExternalLink"; import Loading from "src/components/Loading"; import SettingsHeader from "src/components/SettingsHeader"; import { Badge } from "src/components/ui/badge"; import { useAlbyMe } from "src/hooks/useAlbyMe"; +import { useNodeDetails } from "src/hooks/useNodeDetails"; +import { backendTypeConfigs } from "src/lib/backendType"; import { useInfo } from "src/hooks/useInfo"; export function About() { const { data: info } = useInfo(); const { data: albyMe, error: albyMeError } = useAlbyMe(); + const lsps2Source = info?.jitChannelsLiquiditySource; + const lsps2Pubkey = lsps2Source?.includes("@") + ? lsps2Source.split("@")[0] + : undefined; + const { data: lsps2NodeDetails } = useNodeDetails(lsps2Pubkey); + const lsps2Label = + lsps2NodeDetails?.alias || + (lsps2Pubkey ? lsps2Pubkey.slice(0, 8) + "..." : lsps2Source); + const lsps2MinPaymentSizeSat = info?.jitChannelsMinPaymentSizeMsat + ? Math.ceil(info.jitChannelsMinPaymentSizeMsat / 1000) + : undefined; + const lsps2MaxPaymentSizeSat = info?.jitChannelsMaxPaymentSizeMsat + ? Math.floor(info.jitChannelsMaxPaymentSizeMsat / 1000) + : undefined; if (!info || (info.albyAccountConnected && !albyMe && !albyMeError)) { return ; @@ -41,10 +59,33 @@ export function About() {

    Lightning Node Backend

    -

    - {info.backendType} -

    +
    +
    + {backendTypeConfigs[info.backendType].icon} +
    +

    {backendTypeConfigs[info.backendType].title}

    +
    + {info.databaseType && ( +
    +

    Database Storage

    +

    + {info.databaseType === "postgres" + ? "PostgreSQL" + : info.databaseType === "sqlite" + ? "SQLite" + : info.databaseType} +

    +
    + )} + {info.backendType === "LDK" && ( +
    +

    VSS

    +

    + {info.ldkVssEnabled ? `Enabled (${info.ldkVssUrl})` : "Disabled"} +

    +
    + )} {info.chainDataSourceType && (

    Chain Data Source

    @@ -56,6 +97,40 @@ export function About() {
    )} + {info.jitChannelsLiquiditySource && ( +
    +

    + Just-in-Time channels Liquidity Source LSPS2 +

    +
    + {lsps2Pubkey ? ( + + {lsps2Label} + + + ) : ( +

    {lsps2Label}

    + )} +

    {info.jitChannelsLiquiditySource}

    + {(lsps2MinPaymentSizeSat || lsps2MaxPaymentSizeSat) && ( +

    + JIT payment size:{" "} + {lsps2MinPaymentSizeSat + ? new Intl.NumberFormat().format(lsps2MinPaymentSizeSat) + : "?"} + {" - "} + {lsps2MaxPaymentSizeSat + ? new Intl.NumberFormat().format(lsps2MaxPaymentSizeSat) + : "?"}{" "} + sats +

    + )} +
    +
    + )}

    Nostr Relays

    {info.relays.map((relay) => ( diff --git a/frontend/src/screens/settings/Backup.tsx b/frontend/src/screens/settings/Backup.tsx index 483da09a..d332cdb9 100644 --- a/frontend/src/screens/settings/Backup.tsx +++ b/frontend/src/screens/settings/Backup.tsx @@ -41,7 +41,12 @@ import { InfoResponse, MnemonicResponse } from "src/types"; import { request } from "src/utils/request"; export default function Backup() { - const { data: info, hasMnemonic } = useInfo(); + const { + data: info, + hasMnemonic, + hasChannelManagement, + hasNodeBackup, + } = useInfo(); const { data: me } = useAlbyMe(); const [unlockPassword, setUnlockPassword] = useState(""); const [decryptedMnemonic, setDecryptedMnemonic] = useState(""); @@ -80,9 +85,11 @@ export default function Backup() { description={ <> - Backup your recovery phrase and channel states. These backups are - for disaster recovery only. To migrate your node, please use the - migration tool.{" "} + Backup your recovery phrase + {hasChannelManagement && " and channel states"}. These backups are + for disaster recovery only. + {hasNodeBackup && + " To migrate your node, please use the migration tool."}{" "} Recovery Phrase

    Your recovery phrase is a group of 12 random words that back - up your wallet on-chain balance. Using them is the only way to - recover access to your wallet on another machine or when you - lose your unlock password. + up your wallet{" "} + {info?.backendType === "LDK" ? "on-chain balance" : "balance"} + . Using them is the only way to recover access to your wallet + on another machine or when you lose your unlock password.

    @@ -166,113 +174,117 @@ export default function Backup() { )} -
    -
    -

    Channels Backup

    -

    - Your lightning balance can only be recovered on-chain by closing - your lightning channels. In case of recovery of your Alby Hub a - request will be sent to your peers to close your existing - channels. To recover the funds from these channels, a channel - backup needs to be created every time you open a new channel. -

    -
    + {hasChannelManagement && ( +
    +
    +

    Channels Backup

    +

    + Your lightning balance can only be recovered on-chain by closing + your lightning channels. In case of recovery of your Alby Hub a + request will be sent to your peers to close your existing + channels. To recover the funds from these channels, a channel + backup needs to be created every time you open a new channel. +

    +
    -
    -
    -

    Automatic Channels Backup

    +
    +
    +

    + Automatic Channels Backup +

    + {info?.albyAccountConnected ? ( + Active + ) : ( + Recommended + )} +
    {info?.albyAccountConnected ? ( - Active + <> +

    + Your channel state is backed up automatically after each + channel creation. Using an external recovery tool and your + recovery phrase, you can recover your funds from channels to + your on-chain balance as long as your channel partners are + online. +

    + {info?.vssSupported && ( + <> +
    +

    + Dynamic Channels Backup With Instant Recovery +

    + {me?.subscription.plan_code && info.ldkVssEnabled ? ( + Active + ) : ( + Alby Cloud + )} +
    +

    + When enabled, your channels state is dynamically updated + and stored end-to-end encrypted by Alby's Versioned + Storage Service. This allows you to recover your + lightning balance with your recovery phrase alone, + without having to close your channels. +

    + + {!info.ldkVssEnabled && + (!me?.subscription.plan_code ? ( + + + + ) : ( + + ))} + + )} + ) : ( - Recommended +
    +
    +

    + Link your Alby Account to enable automatic channel backups + after each channel creation. +

    + +
    +
    +
    +

    + Manual Channels Backup +

    + Active +
    +

    + + To backup your channels state manually, without Alby + Account linked, follow the + {" "} + + manual backups guide + + +

    +
    +
    )}
    - {info?.albyAccountConnected ? ( - <> -

    - Your channel state is backed up automatically after each - channel creation. Using an external recovery tool and your - recovery phrase, you can recover your funds from channels to - your on-chain balance as long as your channel partners are - online. -

    - {info?.vssSupported && ( - <> -
    -

    - Dynamic Channels Backup With Instant Recovery -

    - {me?.subscription.plan_code && info.ldkVssEnabled ? ( - Active - ) : ( - Alby Cloud - )} -
    -

    - When enabled, your channels state is dynamically updated - and stored end-to-end encrypted by Alby's Versioned - Storage Service. This allows you to recover your lightning - balance with your recovery phrase alone, without having to - close your channels. -

    - - {!info.ldkVssEnabled && - (!me?.subscription.plan_code ? ( - - - - ) : ( - - ))} - - )} - - ) : ( -
    -
    -

    - Link your Alby Account to enable automatic channel backups - after each channel creation. -

    - -
    -
    -
    -

    - Manual Channels Backup -

    - Active -
    -

    - - To backup your channels state manually, without Alby - Account linked, follow the - {" "} - - manual backups guide - - -

    -
    -
    - )}
    -
    + )} - {!hasMnemonic && !info?.vssSupported && ( + {!hasMnemonic && !hasChannelManagement && !info?.vssSupported && (

    No recovery phrase or channel state backup present.

    diff --git a/frontend/src/screens/settings/DeveloperSettings.tsx b/frontend/src/screens/settings/DeveloperSettings.tsx index 68fe28c8..2dfe1b98 100644 --- a/frontend/src/screens/settings/DeveloperSettings.tsx +++ b/frontend/src/screens/settings/DeveloperSettings.tsx @@ -13,10 +13,12 @@ import { Separator } from "src/components/ui/separator"; import { useAlbyMe } from "src/hooks/useAlbyMe"; import { copyToClipboard } from "src/lib/clipboard"; import { AuthTokenResponse } from "src/types"; +import { isHttpMode } from "src/utils/isHttpMode"; import { request } from "src/utils/request"; export default function DeveloperSettings() { const { data: albyMe } = useAlbyMe(); + const _isHttpMode = isHttpMode(); const [token, setToken] = React.useState(); const [tokenPermission, setTokenPermission] = React.useState(); const [expiryDays, setExpiryDays] = React.useState("365"); @@ -103,7 +105,14 @@ export default function DeveloperSettings() { be removed entirely in the future.
    - {!token && !showCreateTokenForm && ( + {!_isHttpMode && ( +
    + API access is not available in the desktop app because it does not + expose an HTTP API. To use the API, install the Alby Hub server + version instead. +
    + )} + {_isHttpMode && !token && !showCreateTokenForm && (
    -
    -
    - -
    - - Your recovery phrase cannot restore funds from lightning channels. - If you had active channels on a different device, contact Alby - support before proceeding. - -
    -
    - setIsBackedUp(!backedUp)} - /> - -
    ); diff --git a/frontend/src/screens/setup/RestoreNode.tsx b/frontend/src/screens/setup/RestoreNode.tsx index e581ac2e..e364eca4 100644 --- a/frontend/src/screens/setup/RestoreNode.tsx +++ b/frontend/src/screens/setup/RestoreNode.tsx @@ -52,9 +52,9 @@ export function RestoreNode() { />

    - If you're running in the cloud, your Alby Hub will restart - automatically. Otherwise, please manually restart your Alby Hub to - finish the restore process. + If you're running in a cloud VM or linux service, your Alby Hub will + restart automatically. Otherwise, please manually restart your Alby + Hub to finish the restore process.

    Waiting for restart...

    @@ -158,9 +158,9 @@ export function RestoreNode() { down.

    - If you're running in the cloud, your Alby Hub will restart - automatically. Otherwise, please manually restart your Alby - Hub to finish the restore process. + If you're running in a cloud VM or linux service, your Alby + Hub will restart automatically. Otherwise, please manually + restart your Alby Hub to finish the restore process.

    diff --git a/frontend/src/screens/setup/SetupAdvanced.tsx b/frontend/src/screens/setup/SetupAdvanced.tsx index 5ee6b738..21bde4d8 100644 --- a/frontend/src/screens/setup/SetupAdvanced.tsx +++ b/frontend/src/screens/setup/SetupAdvanced.tsx @@ -1,8 +1,15 @@ +import { useEffect } from "react"; import Container from "src/components/Container"; import TwoColumnLayoutHeader from "src/components/TwoColumnLayoutHeader"; import { LinkButton } from "src/components/ui/custom/link-button"; +import useSetupStore from "src/state/SetupStore"; export function SetupAdvanced() { + useEffect(() => { + // in case the user goes back, reset the imported mnemonic flag + useSetupStore.getState().setHasImportedMnemonic(false); + }, []); + return (
    diff --git a/frontend/src/screens/setup/SetupNode.tsx b/frontend/src/screens/setup/SetupNode.tsx index 4e245b13..0cd5349e 100644 --- a/frontend/src/screens/setup/SetupNode.tsx +++ b/frontend/src/screens/setup/SetupNode.tsx @@ -1,55 +1,20 @@ -import React, { ReactElement } from "react"; +import React from "react"; import { useNavigate } from "react-router"; import Container from "src/components/Container"; import TwoColumnLayoutHeader from "src/components/TwoColumnLayoutHeader"; -import { LDKIcon } from "src/components/icons/LDK"; -import { PhoenixdIcon } from "src/components/icons/Phoenixd"; import { Button } from "src/components/ui/button"; import { cn } from "src/lib/utils"; import { BackendType } from "src/types"; -import cashu from "src/assets/images/node/cashu.png"; -import cln from "src/assets/images/node/cln.png"; -import lnd from "src/assets/images/node/lnd.png"; import { backendTypeConfigs } from "src/lib/backendType"; import useSetupStore from "src/state/SetupStore"; -type BackendTypeDisplayConfig = { - title: string; - icon: ReactElement; -}; - -const backendTypeDisplayConfigs: Partial< - Record -> = { - LDK: { - title: "LDK", - icon: , - }, - PHOENIX: { - title: "phoenixd", - icon: , - }, - LND: { - title: "LND", - icon: , - }, - CASHU: { - title: "Cashu Mint", - icon: , - }, - CLN: { - title: "CLN", - icon: , - }, -}; - -const backendTypeDisplayConfigList = Object.entries( - backendTypeDisplayConfigs -).map((entry) => ({ - ...entry[1], - backendType: entry[0] as BackendType, -})); +const backendTypeDisplayConfigList = Object.entries(backendTypeConfigs).map( + (entry) => ({ + ...entry[1], + backendType: entry[0] as BackendType, + }) +); export function SetupNode() { const navigate = useNavigate(); @@ -64,7 +29,8 @@ export function SetupNode() { navigate(`/setup/node/${selectedBackendType.toLowerCase()}`); } - const hasImportedMnemonic = !!setupStore.nodeInfo.mnemonic; + const hasImportedMnemonic = + !!setupStore.nodeInfo.mnemonic && setupStore.hasImportedMnemonic; return ( diff --git a/frontend/src/screens/setup/SetupSecurity.tsx b/frontend/src/screens/setup/SetupSecurity.tsx index e170ec41..74f2467a 100644 --- a/frontend/src/screens/setup/SetupSecurity.tsx +++ b/frontend/src/screens/setup/SetupSecurity.tsx @@ -1,5 +1,7 @@ import { + ClockIcon, HandCoinsIcon, + InfoIcon, LandmarkIcon, ShieldAlertIcon, UnlockIcon, @@ -12,6 +14,12 @@ import TwoColumnLayoutHeader from "src/components/TwoColumnLayoutHeader"; import { Button } from "src/components/ui/button"; import { Checkbox } from "src/components/ui/checkbox"; import { Label } from "src/components/ui/label"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "src/components/ui/tooltip"; import { useInfo } from "src/hooks/useInfo"; import useSetupStore from "src/state/SetupStore"; @@ -36,17 +44,18 @@ export function SetupSecurity() { />
    - {store.nodeInfo.backendType !== "CASHU" && ( -
    -
    - + {store.nodeInfo.backendType !== "CASHU" && + store.nodeInfo.backendType !== "BARK" && ( +
    +
    + +
    + + Alby Hub is a spending wallet - do not keep all your savings + on it! +
    - - Alby Hub is a spending wallet - do not keep all your savings on - it! - -
    - )} + )} {store.nodeInfo.backendType === "CASHU" && (
    @@ -58,6 +67,64 @@ export function SetupSecurity() {
    )} + {store.nodeInfo.backendType === "BARK" && ( + <> +
    + + + Bark is in beta - use at your own risk! + +
    +
    + + + Your funds must be refreshed periodically{" "} + + + + + + + Your virtual UTXOs (VTXOs) will be refreshed + automatically to ensure you maintain ownership. + Refreshes may incur a small fee. Keep your Alby Hub + online so your funds do not expire. + + + + +
    +
    + + + Unilateral exit is not implemented in Alby Hub yet{" "} + + + + + + + You will need to take your hub wallet data and execute + the exit yourself. + + + + +
    + +

    + By using Bark you agree to{" "} + + Second's Terms of Service + + . +

    +
    + + )}
    @@ -81,6 +148,19 @@ export function SetupSecurity() { choose the LDK node type.
    + ) : store.hasImportedMnemonic && + store.nodeInfo.backendType === "LDK" ? ( +
    +
    + +
    + + Your recovery phrase can only restore funds from lightning + channels if you have dynamic channel backups enabled. If you had + active channels on a different device, contact Alby support + before proceeding. + +
    ) : (
    diff --git a/frontend/src/screens/setup/node/BarkForm.tsx b/frontend/src/screens/setup/node/BarkForm.tsx new file mode 100644 index 00000000..153b3e16 --- /dev/null +++ b/frontend/src/screens/setup/node/BarkForm.tsx @@ -0,0 +1,33 @@ +import { wordlist } from "@scure/bip39/wordlists/english.js"; +import { useEffect } from "react"; +import { useNavigate, useSearchParams } from "react-router"; +import useSetupStore from "src/state/SetupStore"; + +import * as bip39 from "@scure/bip39"; +import Loading from "src/components/Loading"; + +export function BarkForm() { + const navigate = useNavigate(); + const [searchParams] = useSearchParams(); + + useEffect(() => { + if (!useSetupStore.getState().nodeInfo.mnemonic) { + useSetupStore.getState().updateNodeInfo({ + mnemonic: bip39.generateMnemonic(wordlist, 128), + }); + } + useSetupStore.getState().updateNodeInfo({ + backendType: "BARK", + }); + navigate("/setup/security", { + replace: true, + }); + }, [navigate, searchParams]); + + return ( + <> + Loading... · Alby Hub + + + ); +} diff --git a/frontend/src/screens/setup/node/LDKForm.tsx b/frontend/src/screens/setup/node/LDKForm.tsx index a3960fea..5ad5f891 100644 --- a/frontend/src/screens/setup/node/LDKForm.tsx +++ b/frontend/src/screens/setup/node/LDKForm.tsx @@ -21,7 +21,9 @@ export function LDKForm() { useSetupStore.getState().updateNodeInfo({ backendType: "LDK", }); - navigate("/setup/security"); + navigate("/setup/security", { + replace: true, + }); }, [navigate, searchParams]); return ( diff --git a/frontend/src/screens/setup/node/PresetNodeForm.tsx b/frontend/src/screens/setup/node/PresetNodeForm.tsx index 279823f4..39fa84e5 100644 --- a/frontend/src/screens/setup/node/PresetNodeForm.tsx +++ b/frontend/src/screens/setup/node/PresetNodeForm.tsx @@ -24,7 +24,9 @@ export function PresetNodeForm() { }); } - navigate("/setup/security"); + navigate("/setup/security", { + replace: true, + }); }, [info, navigate, searchParams]); return ( diff --git a/frontend/src/screens/wallet/Lightning.tsx b/frontend/src/screens/wallet/Lightning.tsx index cc299fd8..259a5e93 100644 --- a/frontend/src/screens/wallet/Lightning.tsx +++ b/frontend/src/screens/wallet/Lightning.tsx @@ -16,7 +16,7 @@ import { useChannels } from "src/hooks/useChannels"; import { useInfo } from "src/hooks/useInfo"; export default function Lightning() { - const { hasChannelManagement } = useInfo(); + const { data: info, hasChannelManagement } = useInfo(); const { data: balances } = useBalances(true); const { data: channels } = useChannels(); @@ -37,7 +37,10 @@ export default function Lightning() { balances.lightning.totalReceivableMsat < balances.lightning.totalSpendableMsat * 0.1; const showOpenFirstChannel = - hasChannelManagement && channels && !hasChannelsOpen; + hasChannelManagement && + channels && + !hasChannelsOpen && + !(info?.jitChannelsEnabled && info?.jitChannelsLiquiditySource); return ( <> diff --git a/frontend/src/screens/wallet/receive/ReceiveInvoice.tsx b/frontend/src/screens/wallet/receive/ReceiveInvoice.tsx index cb96ba5b..f7577696 100644 --- a/frontend/src/screens/wallet/receive/ReceiveInvoice.tsx +++ b/frontend/src/screens/wallet/receive/ReceiveInvoice.tsx @@ -1,19 +1,30 @@ import { + AlertTriangleIcon, ArrowLeftIcon, CopyIcon, LinkIcon, PlusIcon, ReceiptTextIcon, } from "lucide-react"; -import TickSVG from "public/images/illustrations/tick.svg"; import React from "react"; +import { Link } from "react-router"; import { toast } from "sonner"; import AppHeader from "src/components/AppHeader"; +import { CurrencyInputField } from "src/components/CurrencyInputField"; +import ExternalLink from "src/components/ExternalLink"; import { FormattedBitcoinAmount } from "src/components/FormattedBitcoinAmount"; import FormattedFiatAmount from "src/components/FormattedFiatAmount"; import Loading from "src/components/Loading"; +import LottieSuccess from "src/components/LottieSuccess"; import LowReceivingCapacityAlert from "src/components/LowReceivingCapacityAlert"; import QRCode from "src/components/QRCode"; +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, +} from "src/components/ui/accordion"; +import { Alert, AlertDescription } from "src/components/ui/alert"; import { Button } from "src/components/ui/button"; import { Card, @@ -23,18 +34,19 @@ import { CardTitle, } from "src/components/ui/card"; import { ExternalLinkButton } from "src/components/ui/custom/external-link-button"; -import { InputWithAdornment } from "src/components/ui/custom/input-with-adornment"; import { LinkButton } from "src/components/ui/custom/link-button"; import { LoadingButton } from "src/components/ui/custom/loading-button"; import { Input } from "src/components/ui/input"; import { Label } from "src/components/ui/label"; import { useAlbyMe } from "src/hooks/useAlbyMe"; import { useBalances } from "src/hooks/useBalances"; +import { useChannels } from "src/hooks/useChannels"; import { useInfo } from "src/hooks/useInfo"; import { useTransaction } from "src/hooks/useTransaction"; import { copyToClipboard } from "src/lib/clipboard"; import { cn } from "src/lib/utils"; +import ReceiveToSelect from "src/screens/wallet/receive/ReceiveToSelect"; import { CreateInvoiceRequest, Transaction } from "src/types"; import { request } from "src/utils/request"; @@ -42,24 +54,60 @@ export default function ReceiveInvoice() { const { data: info, hasChannelManagement } = useInfo(); const { data: me } = useAlbyMe(); const { data: balances } = useBalances(); + const { data: channels } = useChannels(); const [isLoading, setLoading] = React.useState(false); + const [jitChannelRequestFailed, setJitChannelRequestFailed] = + React.useState(false); const [amountSat, setAmountSat] = React.useState(""); const [description, setDescription] = React.useState(""); + const [toAppId, setToAppId] = React.useState(); const [transaction, setTransaction] = React.useState( null ); - const [paymentDone, setPaymentDone] = React.useState(false); const { data: invoiceData } = useTransaction( transaction ? transaction.paymentHash : "", true ); - - React.useEffect(() => { - if (invoiceData?.settledAt) { - setPaymentDone(true); + const paymentDone = !!invoiceData?.settledAt; + const jitChannelsEnabled = !!info?.jitChannelsEnabled; + const configuredLsps2Source = info?.jitChannelsLiquiditySource; + const lsps2Source = jitChannelsEnabled ? configuredLsps2Source : undefined; + const lsps2MinimumPaymentSizeSat = React.useMemo(() => { + if (jitChannelsEnabled && info?.jitChannelsMinPaymentSizeMsat) { + return Math.ceil(info.jitChannelsMinPaymentSizeMsat / 1000); } - }, [invoiceData]); + return undefined; + }, [info?.jitChannelsMinPaymentSizeMsat, jitChannelsEnabled]); + // only enforce the minimum on the input when the user has no channels yet - + // their first channel must meet the minimum size. + const jitMinimumReceiveSat = channels?.length + ? undefined + : lsps2MinimumPaymentSizeSat; + const lsps2MaximumPaymentSizeSat = React.useMemo(() => { + if (jitChannelsEnabled && info?.jitChannelsMaxPaymentSizeMsat) { + return Math.floor(info.jitChannelsMaxPaymentSizeMsat / 1000); + } + return undefined; + }, [info?.jitChannelsMaxPaymentSizeMsat, jitChannelsEnabled]); + const jitMaximumReceiveSat = + hasChannelManagement && lsps2Source + ? lsps2MaximumPaymentSizeSat + : !lsps2Source && hasChannelManagement + ? balances?.lightning.totalReceivableSat + : undefined; + const totalReceivableMsat = balances?.lightning.totalReceivableMsat ?? 0; + const requestedAmountMsat = +amountSat * 1000 || transaction?.amountMsat || 0; + const isNearReceivingCapacity = + !!hasChannelManagement && requestedAmountMsat >= 0.8 * totalReceivableMsat; + const isJitReceiveInvoice = + !!hasChannelManagement && + !!lsps2Source && + !!transaction && + transaction.amountMsat > totalReceivableMsat; + const displayedJitFeeMsat = paymentDone + ? (invoiceData?.feesPaidMsat ?? 0) + : (transaction?.feesPaidMsat ?? 0); if (!balances || !info || (info.albyAccountConnected && !me)) { return ; @@ -70,6 +118,7 @@ export default function ReceiveInvoice() { try { setLoading(true); + setJitChannelRequestFailed(false); const invoice = await request("/api/invoices", { method: "POST", headers: { @@ -78,6 +127,7 @@ export default function ReceiveInvoice() { body: JSON.stringify({ amountMsat: (parseInt(amountSat) || 0) * 1000, description, + toAppId, } as CreateInvoiceRequest), }); @@ -88,9 +138,29 @@ export default function ReceiveInvoice() { toast("Successfully created invoice"); } } catch (e) { + const requestedAmountSat = parseInt(amountSat) || 0; + // the user already has channels but this amount exceeds their receiving + // capacity (so a new channel is needed) and is below the LSP's minimum + // channel size - the receive may have failed because the amount was too + // small to open a second channel, so add a hint alongside the error. + const likelyTooSmallForNewChannel = + jitChannelsEnabled && + !!channels?.length && + !!lsps2MinimumPaymentSizeSat && + requestedAmountSat < lsps2MinimumPaymentSizeSat && + requestedAmountSat * 1000 > totalReceivableMsat; + let description = "" + e; + if (likelyTooSmallForNewChannel) { + description += `\n\nThis amount is over your receiving capacity and may be too small to open a new Lightning channel. Try receiving at least ${new Intl.NumberFormat().format( + lsps2MinimumPaymentSizeSat as number + )} sats, or lower the amount to fit your current capacity.`; + } toast.error("Failed to create invoice", { - description: "" + e, + description, }); + if (lsps2Source) { + setJitChannelRequestFailed(true); + } console.error(e); } finally { setLoading(false); @@ -101,6 +171,19 @@ export default function ReceiveInvoice() { copyToClipboard(transaction?.invoice as string); }; + const newChannelFeeAlert = ( +

    + Includes a {" "} + channel fee.{" "} + + Learn more + +

    + ); + return (
    - {hasChannelManagement && - (+amountSat * 1000 || transaction?.amountMsat || 0) >= - 0.8 * balances.lightning.totalReceivableMsat && ( - - )} + {!lsps2Source && !transaction && isNearReceivingCapacity && ( + + )}
    {transaction ? ( {!paymentDone ? ( <> - - -

    Waiting for payment

    + + +

    Waiting for Payment...

    - +

    + {isJitReceiveInvoice && displayedJitFeeMsat >= 1000 && ( +
    {newChannelFeeAlert}
    + )}
    - + @@ -158,7 +245,7 @@ export default function ReceiveInvoice() { - +

    - + - + Back to Wallet @@ -197,26 +283,51 @@ export default function ReceiveInvoice() {
    ) : (
    -
    - - { - setAmountSat(e.target.value.trim()); - }} - min={1} - autoFocus - endAdornment={ - + { + if ( + jitMinimumReceiveSat && + e.currentTarget.validity.rangeUnderflow + ) { + e.currentTarget.setCustomValidity( + `You need to receive at least ${new Intl.NumberFormat().format( + jitMinimumReceiveSat + )} sats to open your first lightning channel` + ); + } else if ( + jitMaximumReceiveSat && + e.currentTarget.validity.rangeOverflow + ) { + e.currentTarget.setCustomValidity( + lsps2Source + ? `This JIT channel setup supports receiving at most ${new Intl.NumberFormat().format( + jitMaximumReceiveSat + )} sats in a single payment` + : `You can receive at most ${new Intl.NumberFormat().format( + jitMaximumReceiveSat + )} sats with your current capacity` + ); + } else { + e.currentTarget.setCustomValidity(""); } - /> -
    + }} + maxSat={jitMaximumReceiveSat} + autoFocus + contextRows={ + hasChannelManagement && !lsps2Source && jitMaximumReceiveSat + ? [ + { + label: "Receive limit", + amountSat: jitMaximumReceiveSat, + }, + ] + : undefined + } + />
    + {(!info?.albyAccountConnected || !me?.lightning_address) && ( -
    - {!info?.albyAccountConnected && - info.backendType === "LDK" && ( + + + + View other ways to receive + + + {!info?.albyAccountConnected && info.supportsBolt12 && ( + + + Lightning Offer + + )} - - Lightning Offer + + Receive from On-chain / Other Cryptocurrency - )} - - - Receive from On-chain / Other Cryptocurrency - -
    + + + )} )}
    + {!transaction && jitChannelRequestFailed && ( + + + + Failed to request a just-in-time channel invoice.{" "} + + Manually open a channel. + + + + )}
    {!transaction && (!info?.albyAccountConnected || !me?.lightning_address) && ( diff --git a/frontend/src/screens/wallet/receive/ReceiveOffer.tsx b/frontend/src/screens/wallet/receive/ReceiveOffer.tsx index 59fd08c2..8685fe65 100644 --- a/frontend/src/screens/wallet/receive/ReceiveOffer.tsx +++ b/frontend/src/screens/wallet/receive/ReceiveOffer.tsx @@ -91,14 +91,18 @@ export default function ReceiveOffer() { Lightning Offer - + {description && (

    {description}

    )}
    - + diff --git a/frontend/src/screens/wallet/receive/ReceiveOnchain.tsx b/frontend/src/screens/wallet/receive/ReceiveOnchain.tsx index c4c378c7..0044c33e 100644 --- a/frontend/src/screens/wallet/receive/ReceiveOnchain.tsx +++ b/frontend/src/screens/wallet/receive/ReceiveOnchain.tsx @@ -2,13 +2,11 @@ import { useEffect, useState } from "react"; import { useNavigate } from "react-router"; import { toast } from "sonner"; import AppHeader from "src/components/AppHeader"; +import { CurrencyInputField } from "src/components/CurrencyInputField"; import { FixedFloatSwapInFlow } from "src/components/FixedFloatSwapInFlow"; -import { FormattedBitcoinAmount } from "src/components/FormattedBitcoinAmount"; -import FormattedFiatAmount from "src/components/FormattedFiatAmount"; import Loading from "src/components/Loading"; import LowReceivingCapacityAlert from "src/components/LowReceivingCapacityAlert"; import { MempoolAlert } from "src/components/MempoolAlert"; -import { InputWithAdornment } from "src/components/ui/custom/input-with-adornment"; import { LoadingButton } from "src/components/ui/custom/loading-button"; import { Label } from "src/components/ui/label"; import { RadioGroup, RadioGroupItem } from "src/components/ui/radio-group"; @@ -126,48 +124,38 @@ export default function ReceiveOnchain() { 0.8 * balances.lightning.totalReceivableMsat && ( )} -
    - - setSwapAmountSat(e.target.value)} - required - endAdornment={ - - } - /> -
    -
    -
    - Receiving Capacity:{" "} - -
    - -
    -
    -
    + : swapInfo.maxAmountSat + : hasChannelManagement + ? balances.lightning.totalReceivableSat * 0.99 + : undefined + } + required + contextRows={ + hasChannelManagement + ? [ + { + label: "Receive limit", + amountSat: balances.lightning.totalReceivableSat, + }, + ] + : undefined + } + />
    +
    + +
    +
    {LIGHTNING_BALANCE_LABEL}
    +
    + ); +} + +function AppOption({ app }: { app: App }) { + return ( +
    + +
    +
    {getAppDisplayName(app.name)}
    +
    +
    + ); +} + +export default function ReceiveToSelect({ appId, onChange }: Props) { + const anchorRef = useComboboxAnchor(); + const [search, setSearch] = React.useState(""); + const { data: appsData } = useApps(APP_SELECT_APPS_LIMIT, undefined, { + name: search, + }); + + const apps = React.useMemo( + () => + [...(appsData?.apps || [])].sort((a, b) => + getAppDisplayName(a.name).localeCompare(getAppDisplayName(b.name)) + ), + [appsData?.apps] + ); + + const options = React.useMemo( + () => [ + { value: LIGHTNING_BALANCE, label: LIGHTNING_BALANCE_LABEL }, + ...apps.map((app) => ({ + value: app.id.toString(), + label: getAppDisplayName(app.name), + app, + })), + ], + [apps] + ); + + const selectedOption = options.find((opt) => + appId ? opt.value === appId.toString() : undefined + ); + + return ( +
    + + option.value} + onInputValueChange={setSearch} + onValueChange={(option) => + onChange( + !option || option.value === LIGHTNING_BALANCE + ? undefined + : Number(option.value) + ) + } + > +
    + + + {selectedOption?.app ? ( + + ) : ( +
    + +
    + )} +
    +
    +
    + + No connections found. + + {(option: ReceiveToOption) => ( + + {option.app ? ( + + ) : ( + + )} + + )} + + +
    +
    + ); +} diff --git a/frontend/src/screens/wallet/send/LnurlPay.tsx b/frontend/src/screens/wallet/send/LnurlPay.tsx index 1d9ac2ae..cabea1f6 100644 --- a/frontend/src/screens/wallet/send/LnurlPay.tsx +++ b/frontend/src/screens/wallet/send/LnurlPay.tsx @@ -5,13 +5,11 @@ import React from "react"; import { Link, useLocation, useNavigate } from "react-router"; import { toast } from "sonner"; import AppHeader from "src/components/AppHeader"; -import { FormattedBitcoinAmount } from "src/components/FormattedBitcoinAmount"; -import FormattedFiatAmount from "src/components/FormattedFiatAmount"; +import { CurrencyInputField } from "src/components/CurrencyInputField"; import { InsufficientLightningBalanceAlert } from "src/components/InsufficientLightningBalanceAlert"; import Loading from "src/components/Loading"; import { PaymentFailedAlert } from "src/components/PaymentFailedAlert"; import { PendingPaymentAlert } from "src/components/PendingPaymentAlert"; -import { InputWithAdornment } from "src/components/ui/custom/input-with-adornment"; import { LinkButton } from "src/components/ui/custom/link-button"; import { LoadingButton } from "src/components/ui/custom/loading-button"; import { Input } from "src/components/ui/input"; @@ -136,42 +134,21 @@ export default function LnurlPay() {

    )} -
    - - { - setAmountSat(e.target.value.trim()); - }} - min={1} - max={balances.lightning.totalSpendableSat} - required - autoFocus - endAdornment={ - - } - /> -
    -
    -
    - Lightning Balance:{" "} - -
    - -
    -
    -
    + {!!lnAddress.lnurlpData?.commentAllowed && (
    diff --git a/frontend/src/screens/wallet/send/Onchain.tsx b/frontend/src/screens/wallet/send/Onchain.tsx index 51d1c8da..3b966137 100644 --- a/frontend/src/screens/wallet/send/Onchain.tsx +++ b/frontend/src/screens/wallet/send/Onchain.tsx @@ -10,15 +10,13 @@ import { Link, useLocation, useNavigate } from "react-router"; import { toast } from "sonner"; import { AnchorReserveAlert } from "src/components/AnchorReserveAlert"; import AppHeader from "src/components/AppHeader"; +import { CurrencyInputField } from "src/components/CurrencyInputField"; import ExternalLink from "src/components/ExternalLink"; -import { FormattedBitcoinAmount } from "src/components/FormattedBitcoinAmount"; -import FormattedFiatAmount from "src/components/FormattedFiatAmount"; import { InsufficientLightningBalanceAlert } from "src/components/InsufficientLightningBalanceAlert"; import Loading from "src/components/Loading"; import { MempoolAlert } from "src/components/MempoolAlert"; import { Alert, AlertDescription, AlertTitle } from "src/components/ui/alert"; import { Button } from "src/components/ui/button"; -import { InputWithAdornment } from "src/components/ui/custom/input-with-adornment"; import { LinkButton } from "src/components/ui/custom/link-button"; import { LoadingButton } from "src/components/ui/custom/loading-button"; import { Input } from "src/components/ui/input"; @@ -190,40 +188,21 @@ function OnchainForm({ return (
    -
    - - { - setAmountSat(e.target.value.trim()); - }} - min={ONCHAIN_DUST_SATS} - max={balances.onchain.spendableSat} - required - autoFocus - endAdornment={ - - } - /> -
    -
    - On-chain Balance:{" "} - -
    - -
    -
    +