Compare commits
21 Commits
main
...
sk-test-rp
Author | SHA1 | Date | |
---|---|---|---|
|
4f6674818c | ||
|
0e23e2f3dc | ||
|
8f0a727373 | ||
|
2b7d120887 | ||
f739050d8c | |||
|
d3d6c2c24d | ||
|
f7fd972516 | ||
|
11fa17ce8b | ||
|
52de3df7da | ||
4e34ea442a | |||
|
e57c4b24ad | ||
|
75989d9f34 | ||
|
77981f5bfb | ||
|
054de34b68 | ||
|
b8a0d0d06f | ||
|
31a174528e | ||
|
c47bee4efd | ||
|
ae4f5cbaa1 | ||
|
f5d82d5292 | ||
|
4c291dc307 | ||
|
982232545a |
30
.env.example
Normal file
30
.env.example
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
# Client-side environment variables (must be prefixed with NEXT_PUBLIC_)
|
||||||
|
|
||||||
|
# Solana Token Payment Configuration
|
||||||
|
# TODO: Use different RPC URL or use browser wallet
|
||||||
|
NEXT_PUBLIC_SOLANA_RPC_URL=https://skilled-prettiest-seed.solana-mainnet.quiknode.pro/eeecfebd04e345f69f1900cc3483cbbfea02a158
|
||||||
|
NEXT_PUBLIC_SOLANA_TOKEN_MINT_ADDRESS=71Jvq4Epe2FCJ7JFSF7jLXdNk1Wy4Bhqd9iL6bEFELvg
|
||||||
|
|
||||||
|
# Multisig address
|
||||||
|
NEXT_PUBLIC_SOLANA_TOKEN_RECIPIENT_ADDRESS=FFDx3SdAEeXrp6BTmStB4BDHpctGsaasZq4FFcowRobY
|
||||||
|
NEXT_PUBLIC_SOLANA_TOKEN_SYMBOL=GOR
|
||||||
|
NEXT_PUBLIC_SOLANA_TOKEN_DECIMALS=6
|
||||||
|
NEXT_PUBLIC_SOLANA_PAYMENT_AMOUNT=400000000 # Approx. 5 USD
|
||||||
|
|
||||||
|
# UI Configuration
|
||||||
|
NEXT_PUBLIC_EXAMPLE_URL=https://git.vdb.to/cerc-io/test-progressive-web-app
|
||||||
|
|
||||||
|
# Server-side environment variables
|
||||||
|
|
||||||
|
# Laconic Registry Configuration
|
||||||
|
REGISTRY_CHAIN_ID=laconic-mainnet
|
||||||
|
REGISTRY_RPC_ENDPOINT=https://laconicd-mainnet-1.laconic.com
|
||||||
|
REGISTRY_GQL_ENDPOINT=https://laconicd-mainnet-1.laconic.com/graphql
|
||||||
|
REGISTRY_BOND_ID=
|
||||||
|
REGISTRY_AUTHORITY=
|
||||||
|
REGISTRY_USER_KEY=
|
||||||
|
REGISTRY_GAS_PRICE=0.001
|
||||||
|
|
||||||
|
# Application Configuration
|
||||||
|
DEPLOYER_LRN=
|
||||||
|
NEXT_PUBLIC_DOMAIN_SUFFIX=
|
8
.gitignore
vendored
8
.gitignore
vendored
@ -31,11 +31,15 @@ yarn-error.log*
|
|||||||
.pnpm-debug.log*
|
.pnpm-debug.log*
|
||||||
|
|
||||||
# env files (can opt-in for committing if needed)
|
# env files (can opt-in for committing if needed)
|
||||||
.env*
|
.env.local
|
||||||
|
.env
|
||||||
|
|
||||||
|
# registry env files
|
||||||
|
.registry.env
|
||||||
|
.app.env
|
||||||
|
|
||||||
# vercel
|
# vercel
|
||||||
.vercel
|
.vercel
|
||||||
|
|
||||||
# typescript
|
# typescript
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
next-env.d.ts
|
|
||||||
|
75
README.md
75
README.md
@ -1,13 +1,15 @@
|
|||||||
# ATOM Deploy - Laconic Registry
|
# GOR Deploy - Laconic Registry
|
||||||
|
|
||||||
A simple Next.js frontend that allows users to pay in ATOM cryptocurrency (using Keplr wallet) and paste a URL. The transaction hash and URL are used to create records in the Laconic Registry.
|
A simple Next.js frontend that allows users to pay in GOR tokens (configurable Solana SPL tokens) using Solana wallets and paste a URL. The transaction hash and URL are used to create records in the Laconic Registry.
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- Keplr wallet integration for ATOM payments
|
- Solana wallet integration (Phantom & Solflare) for GOR token payments
|
||||||
|
- Configurable Solana SPL token support (defaults to GOR)
|
||||||
- URL validation and submission
|
- URL validation and submission
|
||||||
- Transaction verification
|
- Solana transaction verification with replay protection
|
||||||
- Laconic Registry record creation using official `@cerc-io/registry-sdk`
|
- Laconic Registry record creation using official `@cerc-io/registry-sdk`
|
||||||
|
- LNT token transfer integration for registry payments
|
||||||
- Automatic salt addition to DNS names to prevent collisions
|
- Automatic salt addition to DNS names to prevent collisions
|
||||||
- Error handling and validation throughout the application flow
|
- Error handling and validation throughout the application flow
|
||||||
|
|
||||||
@ -15,7 +17,7 @@ A simple Next.js frontend that allows users to pay in ATOM cryptocurrency (using
|
|||||||
|
|
||||||
- Node.js 18.x or later
|
- Node.js 18.x or later
|
||||||
- npm or yarn
|
- npm or yarn
|
||||||
- Keplr wallet browser extension
|
- Solana wallet browser extension (Phantom or Solflare)
|
||||||
- Access to a Laconic Registry node
|
- Access to a Laconic Registry node
|
||||||
|
|
||||||
## Environment Variables
|
## Environment Variables
|
||||||
@ -29,10 +31,12 @@ cp .env.local.example .env.local
|
|||||||
Required environment variables:
|
Required environment variables:
|
||||||
|
|
||||||
Client-side (must be prefixed with NEXT_PUBLIC_):
|
Client-side (must be prefixed with NEXT_PUBLIC_):
|
||||||
- `NEXT_PUBLIC_RECIPIENT_ADDRESS` - The Cosmos address that will receive ATOM payments
|
- `NEXT_PUBLIC_SOLANA_RPC_URL` - The RPC URL for the Solana blockchain
|
||||||
- `NEXT_PUBLIC_COSMOS_RPC_URL` - The RPC URL for the Cosmos blockchain (used by Keplr for transactions)
|
- `NEXT_PUBLIC_SOLANA_TOKEN_MINT_ADDRESS` - The mint address of the SPL token to accept
|
||||||
- `NEXT_PUBLIC_COSMOS_API_URL` - The REST API URL for the Cosmos blockchain (used for transaction queries)
|
- `NEXT_PUBLIC_SOLANA_TOKEN_RECIPIENT_ADDRESS` - The Solana address that will receive token payments
|
||||||
- `NEXT_PUBLIC_COSMOS_CHAIN_ID` - The chain ID for Keplr wallet (e.g., cosmoshub-4)
|
- `NEXT_PUBLIC_SOLANA_TOKEN_SYMBOL` - The token symbol to display (e.g., "GOR")
|
||||||
|
- `NEXT_PUBLIC_SOLANA_TOKEN_DECIMALS` - The number of decimals for the token (e.g., 6)
|
||||||
|
- `NEXT_PUBLIC_SOLANA_PAYMENT_AMOUNT` - The fixed payment amount required (e.g., 400)
|
||||||
- `NEXT_PUBLIC_DOMAIN_SUFFIX` - Optional suffix to append to DNS names in the UI (e.g. ".example.com")
|
- `NEXT_PUBLIC_DOMAIN_SUFFIX` - Optional suffix to append to DNS names in the UI (e.g. ".example.com")
|
||||||
- `NEXT_PUBLIC_EXAMPLE_URL` - Example URL to pre-fill in the URL form (e.g. "https://github.com/cerc-io/laconic-registry-cli")
|
- `NEXT_PUBLIC_EXAMPLE_URL` - Example URL to pre-fill in the URL form (e.g. "https://github.com/cerc-io/laconic-registry-cli")
|
||||||
|
|
||||||
@ -42,8 +46,8 @@ Server-side:
|
|||||||
- `REGISTRY_RPC_ENDPOINT` - The RPC endpoint for the Laconic Registry
|
- `REGISTRY_RPC_ENDPOINT` - The RPC endpoint for the Laconic Registry
|
||||||
- `REGISTRY_BOND_ID` - The bond ID to use for Laconic Registry records
|
- `REGISTRY_BOND_ID` - The bond ID to use for Laconic Registry records
|
||||||
- `REGISTRY_AUTHORITY` - The authority for Laconic Registry LRNs
|
- `REGISTRY_AUTHORITY` - The authority for Laconic Registry LRNs
|
||||||
- `REGISTRY_USER_KEY` - The private key for Laconic Registry transactions
|
- `REGISTRY_USER_KEY` - The private key for Laconic Registry transactions (also used for LNT transfers)
|
||||||
- `APP_NAME` - The name of the application (used in record creation)
|
- `APP_NAME` - The name of the application (used in record creation, defaults to "gor-deploy")
|
||||||
- `DEPLOYER_LRN` - The LRN of the deployer
|
- `DEPLOYER_LRN` - The LRN of the deployer
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
@ -74,24 +78,25 @@ npm start
|
|||||||
|
|
||||||
## How It Works
|
## How It Works
|
||||||
|
|
||||||
1. User connects their Keplr wallet to the application
|
1. User connects their Solana wallet (Phantom or Solflare) to the application
|
||||||
2. User enters a URL they want to deploy to the Laconic Registry
|
2. User enters a URL they want to deploy to the Laconic Registry
|
||||||
3. User completes payment in ATOM to a specified address
|
3. User completes payment in GOR tokens (or configured SPL token) to a specified Solana address
|
||||||
4. The application verifies the transaction using the Cosmos RPC
|
4. The application verifies the Solana transaction with replay protection
|
||||||
5. The application calls a server-side API route which creates records in the Laconic Registry
|
5. After payment verification, the server transfers LNT tokens from a prefilled account to the service provider
|
||||||
6. The server generates a unique DNS name by adding a random salt to prevent name collisions
|
6. The application calls a server-side API route which creates records in the Laconic Registry using the LNT transfer hash
|
||||||
7. Two records are created in the Laconic Registry:
|
7. The server generates a unique DNS name by adding a random salt to prevent name collisions
|
||||||
|
8. Two records are created in the Laconic Registry:
|
||||||
- An ApplicationRecord containing metadata about the URL
|
- An ApplicationRecord containing metadata about the URL
|
||||||
- An ApplicationDeploymentRequest linking the URL, DNS, and payment transaction
|
- An ApplicationDeploymentRequest linking the URL, DNS, and payment details with external_payment metadata
|
||||||
|
|
||||||
### Architecture
|
### Architecture
|
||||||
|
|
||||||
This application uses a hybrid client/server approach:
|
This application uses a hybrid client/server approach:
|
||||||
|
|
||||||
- Client-side: Handles the user interface, Keplr wallet integration, and transaction verification
|
- Client-side: Handles the user interface, Solana wallet integration, and transaction verification
|
||||||
- Server-side: Next.js API route handles the communication with the Laconic Registry
|
- Server-side: Next.js API route handles LNT transfers and communication with the Laconic Registry
|
||||||
|
|
||||||
This architecture allows us to keep sensitive keys secure on the server side while providing a responsive user experience.
|
This architecture allows us to keep sensitive keys secure on the server side while providing a responsive user experience. The dual-payment system (Solana → LNT → Registry) enables cross-chain payment acceptance.
|
||||||
|
|
||||||
### Resource Name Formats
|
### Resource Name Formats
|
||||||
|
|
||||||
@ -115,7 +120,7 @@ The Laconic Resource Names (LRNs) are generated with the following format:
|
|||||||
lrn://{authority}/applications/{app-name}-{short-commit-hash}-{random-salt}
|
lrn://{authority}/applications/{app-name}-{short-commit-hash}-{random-salt}
|
||||||
```
|
```
|
||||||
|
|
||||||
For example: `lrn://atom/applications/github-abc123-xyz789`
|
For example: `lrn://gor/applications/github-abc123-xyz789`
|
||||||
|
|
||||||
Including the commit hash and salt in the LRN ensures that each application record has a unique identifier, consistently matching the DNS naming pattern.
|
Including the commit hash and salt in the LRN ensures that each application record has a unique identifier, consistently matching the DNS naming pattern.
|
||||||
|
|
||||||
@ -123,7 +128,7 @@ Including the commit hash and salt in the LRN ensures that each application reco
|
|||||||
|
|
||||||
This application was built with reference to:
|
This application was built with reference to:
|
||||||
- `snowballtools-base/packages/backend/src/registry.ts`
|
- `snowballtools-base/packages/backend/src/registry.ts`
|
||||||
- `hosted-frontends/deploy-atom.sh`
|
- Original `hosted-frontends/deploy-atom.sh` (adapted for Solana/GOR)
|
||||||
|
|
||||||
## Deployment to Production
|
## Deployment to Production
|
||||||
|
|
||||||
@ -158,24 +163,32 @@ CMD ["npm", "start"]
|
|||||||
Build and run the Docker container:
|
Build and run the Docker container:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker build -t atom-deploy .
|
docker build -t gor-deploy .
|
||||||
docker run -p 3000:3000 --env-file .env.production atom-deploy
|
docker run -p 3000:3000 --env-file .env.production gor-deploy
|
||||||
```
|
```
|
||||||
|
|
||||||
## Known Issues
|
## Known Issues
|
||||||
|
|
||||||
- You may see a deprecated Buffer() warning during build. This comes from dependencies in the registry-sdk. This doesn't affect functionality.
|
- You may see a deprecated Buffer() warning during build. This comes from dependencies in the registry-sdk. This doesn't affect functionality.
|
||||||
- If using a custom Cosmos chain, ensure that your RPC endpoint supports CORS for client-side requests.
|
- Ensure that your Solana RPC endpoint supports CORS for client-side requests.
|
||||||
- The Keplr wallet integration requires HTTPS in production environments.
|
- Solana wallet integrations require HTTPS in production environments.
|
||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
|
|
||||||
### Keplr Wallet Issues
|
### Solana Wallet Issues
|
||||||
|
|
||||||
- **Keplr not detecting**: Install the Keplr browser extension and refresh the page.
|
- **Wallet not detecting**: Install the Phantom or Solflare browser extension and refresh the page.
|
||||||
- **Chain not found in Keplr**: The application will attempt to suggest the chain to Keplr, but if that fails, you may need to manually add the chain in your Keplr wallet settings.
|
- **Connection issues**: Ensure the wallet is unlocked and try refreshing the page.
|
||||||
|
- **Transaction failures**: Check that you have sufficient SOL for transaction fees and enough tokens for the payment.
|
||||||
|
|
||||||
|
### Token Configuration
|
||||||
|
|
||||||
|
- **Wrong token**: Verify the `NEXT_PUBLIC_SOLANA_TOKEN_MINT_ADDRESS` matches your desired SPL token.
|
||||||
|
- **Incorrect decimals**: Ensure `NEXT_PUBLIC_SOLANA_TOKEN_DECIMALS` matches the token's actual decimal count.
|
||||||
|
- **Payment amount**: Adjust `NEXT_PUBLIC_SOLANA_PAYMENT_AMOUNT` to the desired payment amount.
|
||||||
|
|
||||||
### Laconic Registry Issues
|
### Laconic Registry Issues
|
||||||
|
|
||||||
- **Failed to create record**: Check that your REGISTRY_USER_KEY and REGISTRY_BOND_ID are correctly set.
|
- **Failed to create record**: Check that your REGISTRY_USER_KEY and REGISTRY_BOND_ID are correctly set.
|
||||||
- **Transaction verification errors**: Ensure your COSMOS_RPC_URL and COSMOS_API_URL are accessible and return correct transaction data.
|
- **LNT transfer errors**: Ensure your REGISTRY_USER_KEY has sufficient LNT balance.
|
||||||
|
- **Transaction verification errors**: Ensure your SOLANA_RPC_URL is accessible and returns correct transaction data.
|
||||||
|
10
deploy/.registry.env.example
Normal file
10
deploy/.registry.env.example
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
# ENV for registry operations
|
||||||
|
|
||||||
|
# Bond to use
|
||||||
|
REGISTRY_BOND_ID=
|
||||||
|
|
||||||
|
# Target deployer LRN
|
||||||
|
DEPLOYER_LRN=
|
||||||
|
|
||||||
|
# Authority to deploy the app under
|
||||||
|
AUTHORITY=
|
40
deploy/Dockerfile
Normal file
40
deploy/Dockerfile
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
ARG VARIANT=20-bullseye
|
||||||
|
FROM node:${VARIANT}
|
||||||
|
|
||||||
|
ARG USERNAME=node
|
||||||
|
ARG NPM_GLOBAL=/usr/local/share/npm-global
|
||||||
|
|
||||||
|
# Add NPM global to PATH.
|
||||||
|
ENV PATH=${NPM_GLOBAL}/bin:${PATH}
|
||||||
|
|
||||||
|
RUN \
|
||||||
|
# Configure global npm install location, use group to adapt to UID/GID changes
|
||||||
|
if ! cat /etc/group | grep -e "^npm:" > /dev/null 2>&1; then groupadd -r npm; fi \
|
||||||
|
&& usermod -a -G npm ${USERNAME} \
|
||||||
|
&& umask 0002 \
|
||||||
|
&& mkdir -p ${NPM_GLOBAL} \
|
||||||
|
&& touch /usr/local/etc/npmrc \
|
||||||
|
&& chown ${USERNAME}:npm ${NPM_GLOBAL} /usr/local/etc/npmrc \
|
||||||
|
&& chmod g+s ${NPM_GLOBAL} \
|
||||||
|
&& npm config -g set prefix ${NPM_GLOBAL} \
|
||||||
|
&& su ${USERNAME} -c "npm config -g set prefix ${NPM_GLOBAL}" \
|
||||||
|
# Install eslint
|
||||||
|
&& su ${USERNAME} -c "umask 0002 && npm install -g eslint" \
|
||||||
|
&& npm cache clean --force > /dev/null 2>&1
|
||||||
|
|
||||||
|
RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \
|
||||||
|
&& apt-get -y install --no-install-recommends jq bash
|
||||||
|
|
||||||
|
# laconic-so
|
||||||
|
RUN curl -LO https://git.vdb.to/cerc-io/stack-orchestrator/releases/download/latest/laconic-so && \
|
||||||
|
chmod +x ./laconic-so && \
|
||||||
|
mv ./laconic-so /usr/bin/laconic-so
|
||||||
|
|
||||||
|
# Configure the npm registry
|
||||||
|
RUN npm config set @cerc-io:registry https://git.vdb.to/api/packages/cerc-io/npm/
|
||||||
|
|
||||||
|
# DEBUG, remove
|
||||||
|
RUN yarn info @cerc-io/laconic-registry-cli
|
||||||
|
|
||||||
|
# Globally install the cli package
|
||||||
|
RUN yarn global add @cerc-io/laconic-registry-cli
|
77
deploy/README.md
Normal file
77
deploy/README.md
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
# Deploy
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
### gor-deploy
|
||||||
|
|
||||||
|
* Clone the repo:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone git@git.vdb.to:LaconicNetwork/gor-deploy.git
|
||||||
|
cd gor-deploy/deploy
|
||||||
|
```
|
||||||
|
|
||||||
|
* Build registry CLI image:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker build -t cerc/laconic-registry-cli .
|
||||||
|
|
||||||
|
# Builds image cerc/laconic-registry-cli:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
* Configure `userKey` and `bondId` in the [registry CLI config](./config.yml):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
nano config.yml
|
||||||
|
```
|
||||||
|
|
||||||
|
* Add configuration for registry operations:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .registry.env.example .registry.env
|
||||||
|
|
||||||
|
# Fill in the required values
|
||||||
|
nano .registry.env
|
||||||
|
```
|
||||||
|
|
||||||
|
* Add configuration for the app:
|
||||||
|
|
||||||
|
<!-- TODO: Use a release -->
|
||||||
|
```bash
|
||||||
|
curl -s https://git.vdb.to/LaconicNetwork/gor-deploy/raw/branch/sk-gor-deploy/.env.example -o .app.env
|
||||||
|
|
||||||
|
# Fill in the required values
|
||||||
|
nano .app.env
|
||||||
|
```
|
||||||
|
|
||||||
|
## Run
|
||||||
|
|
||||||
|
### gor-deploy
|
||||||
|
|
||||||
|
* Deploy `gor-deploy` App:
|
||||||
|
<!-- TODO: Change deploy dns -->
|
||||||
|
```bash
|
||||||
|
# In gor-deploy/deploy dir
|
||||||
|
docker run -it \
|
||||||
|
-v ./:/app/deploy -w /app/deploy \
|
||||||
|
-e DEPLOYMENT_DNS=mtm-deploy \
|
||||||
|
cerc/laconic-registry-cli:latest \
|
||||||
|
./deploy.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
* Check deployment logs on deployer UI: <https://webapp-deployer-ui.apps.vaasl.io/>
|
||||||
|
|
||||||
|
* Visit deployed app: <https://gor-deploy.apps.vaasl.io>
|
||||||
|
|
||||||
|
### remove deployment
|
||||||
|
|
||||||
|
* Remove deployment:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# In gor-deploy/deploy dir
|
||||||
|
docker run -it \
|
||||||
|
-v ./:/app/deploy -w /app/deploy \
|
||||||
|
-e DEPLOYMENT_RECORD_ID=<deploment-record-id-to-be-removed> \
|
||||||
|
cerc/laconic-registry-cli:latest \
|
||||||
|
./remove-deployment.sh
|
||||||
|
```
|
9
deploy/config.yml
Normal file
9
deploy/config.yml
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
# Registry CLI config
|
||||||
|
services:
|
||||||
|
registry:
|
||||||
|
rpcEndpoint: 'https://laconicd-mainnet-1.laconic.com'
|
||||||
|
gqlEndpoint: 'https://laconicd-mainnet-1.laconic.com/api'
|
||||||
|
userKey:
|
||||||
|
bondId:
|
||||||
|
chainId: laconic-mainnet
|
||||||
|
gasPrice: 0.001alnt
|
131
deploy/deploy.sh
Executable file
131
deploy/deploy.sh
Executable file
@ -0,0 +1,131 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Fail on error
|
||||||
|
set -e
|
||||||
|
|
||||||
|
source .registry.env
|
||||||
|
echo "Using REGISTRY_BOND_ID: $REGISTRY_BOND_ID"
|
||||||
|
echo "Using DEPLOYER_LRN: $DEPLOYER_LRN"
|
||||||
|
echo "Using AUTHORITY: $AUTHORITY"
|
||||||
|
|
||||||
|
# Repository URL
|
||||||
|
REPO_URL="https://git.vdb.to/LaconicNetwork/gor-deploy"
|
||||||
|
|
||||||
|
# Get the latest commit hash for a branch
|
||||||
|
# TODO: Change to main before merging PR
|
||||||
|
BRANCH_NAME="sk-gor-deploy"
|
||||||
|
LATEST_HASH=$(git ls-remote $REPO_URL refs/heads/$BRANCH_NAME | awk '{print $1}')
|
||||||
|
|
||||||
|
# TODO: Use a release
|
||||||
|
PACKAGE_VERSION=$(curl -s $REPO_URL/raw/branch/$BRANCH_NAME/package.json | jq -r .version)
|
||||||
|
|
||||||
|
# TODO: Update app name
|
||||||
|
APP_NAME=mtm-deploy
|
||||||
|
|
||||||
|
echo "Repo: ${REPO_URL}"
|
||||||
|
echo "Latest hash: ${LATEST_HASH}"
|
||||||
|
echo "App version: ${PACKAGE_VERSION}"
|
||||||
|
echo "Deployment DNS: ${DEPLOYMENT_DNS}"
|
||||||
|
|
||||||
|
# Current date and time for note
|
||||||
|
CURRENT_DATE_TIME=$(date -u)
|
||||||
|
|
||||||
|
CONFIG_FILE=config.yml
|
||||||
|
|
||||||
|
# Reference: https://git.vdb.to/cerc-io/test-progressive-web-app/src/branch/main/scripts
|
||||||
|
|
||||||
|
# Get latest version from registry and increment application-record version
|
||||||
|
NEW_APPLICATION_VERSION=$(laconic -c $CONFIG_FILE registry record list --type ApplicationRecord --all --name "$APP_NAME" 2>/dev/null | jq -r -s ".[] | sort_by(.createTime) | reverse | [ .[] | select(.bondId == \"$REGISTRY_BOND_ID\") ] | .[0].attributes.version" | awk -F. -v OFS=. '{$NF += 1 ; print}')
|
||||||
|
|
||||||
|
if [ -z "$NEW_APPLICATION_VERSION" ] || [ "1" == "$NEW_APPLICATION_VERSION" ]; then
|
||||||
|
# Set application-record version if no previous records were found
|
||||||
|
NEW_APPLICATION_VERSION=0.0.1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Generate application-record.yml with incremented version
|
||||||
|
mkdir -p records
|
||||||
|
RECORD_FILE=./records/application-record.yml
|
||||||
|
|
||||||
|
cat >$RECORD_FILE <<EOF
|
||||||
|
record:
|
||||||
|
type: ApplicationRecord
|
||||||
|
version: $NEW_APPLICATION_VERSION
|
||||||
|
repository_ref: $LATEST_HASH
|
||||||
|
repository: ["$REPO_URL"]
|
||||||
|
app_type: webapp
|
||||||
|
name: $APP_NAME
|
||||||
|
app_version: $PACKAGE_VERSION
|
||||||
|
EOF
|
||||||
|
|
||||||
|
echo "Application record generated successfully: $RECORD_FILE"
|
||||||
|
|
||||||
|
# Publish ApplicationRecord
|
||||||
|
publish_response=$(laconic -c $CONFIG_FILE registry record publish --filename $RECORD_FILE)
|
||||||
|
rc=$?
|
||||||
|
if [ $rc -ne 0 ]; then
|
||||||
|
echo "FATAL: Failed to publish record"
|
||||||
|
exit $rc
|
||||||
|
fi
|
||||||
|
RECORD_ID=$(echo $publish_response | jq -r '.id')
|
||||||
|
echo "ApplicationRecord published, setting names next"
|
||||||
|
echo $RECORD_ID
|
||||||
|
|
||||||
|
# Set name to record
|
||||||
|
REGISTRY_APP_LRN="lrn://$AUTHORITY/applications/$APP_NAME"
|
||||||
|
|
||||||
|
name1="$REGISTRY_APP_LRN@${PACKAGE_VERSION}"
|
||||||
|
sleep 2
|
||||||
|
laconic -c $CONFIG_FILE registry name set "$name1" "$RECORD_ID"
|
||||||
|
rc=$?
|
||||||
|
if [ $rc -ne 0 ]; then
|
||||||
|
echo "FATAL: Failed to set name: $REGISTRY_APP_LRN@${PACKAGE_VERSION}"
|
||||||
|
exit $rc
|
||||||
|
fi
|
||||||
|
echo "$name1 set for ApplicationRecord"
|
||||||
|
|
||||||
|
name2="$REGISTRY_APP_LRN@${LATEST_HASH}"
|
||||||
|
sleep 2
|
||||||
|
laconic -c $CONFIG_FILE registry name set "$name2" "$RECORD_ID"
|
||||||
|
rc=$?
|
||||||
|
if [ $rc -ne 0 ]; then
|
||||||
|
echo "FATAL: Failed to set hash"
|
||||||
|
exit $rc
|
||||||
|
fi
|
||||||
|
echo "$name2 set for ApplicationRecord"
|
||||||
|
|
||||||
|
name3="$REGISTRY_APP_LRN"
|
||||||
|
sleep 2
|
||||||
|
# Set name if latest release
|
||||||
|
laconic -c $CONFIG_FILE registry name set "$name3" "$RECORD_ID"
|
||||||
|
rc=$?
|
||||||
|
if [ $rc -ne 0 ]; then
|
||||||
|
echo "FATAL: Failed to set release"
|
||||||
|
exit $rc
|
||||||
|
fi
|
||||||
|
echo "$name3 set for ApplicationRecord"
|
||||||
|
|
||||||
|
# Check if record found for REGISTRY_APP_LRN
|
||||||
|
query_response=$(laconic -c $CONFIG_FILE registry name resolve "$REGISTRY_APP_LRN")
|
||||||
|
rc=$?
|
||||||
|
if [ $rc -ne 0 ]; then
|
||||||
|
echo "FATAL: Failed to query name"
|
||||||
|
exit $rc
|
||||||
|
fi
|
||||||
|
APP_RECORD=$(echo $query_response | jq '.[0]')
|
||||||
|
if [ -z "$APP_RECORD" ] || [ "null" == "$APP_RECORD" ]; then
|
||||||
|
echo "No record found for $REGISTRY_APP_LRN."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "Name resolution successful"
|
||||||
|
|
||||||
|
sleep 2
|
||||||
|
echo "Requesting a webapp deployment for $name2, using deployer $DEPLOYER_LRN"
|
||||||
|
laconic-so request-webapp-deployment \
|
||||||
|
--laconic-config $CONFIG_FILE \
|
||||||
|
--deployer $DEPLOYER_LRN \
|
||||||
|
--app $name2 \
|
||||||
|
--env-file ./.app.env \
|
||||||
|
--dns $DEPLOYMENT_DNS \
|
||||||
|
--make-payment auto
|
||||||
|
|
||||||
|
echo "Done"
|
0
deploy/records/.gitkeep
Normal file
0
deploy/records/.gitkeep
Normal file
63
deploy/remove-deployment.sh
Executable file
63
deploy/remove-deployment.sh
Executable file
@ -0,0 +1,63 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
if [[ -z $DEPLOYMENT_RECORD_ID ]]; then
|
||||||
|
echo "Error: please pass the deployment record ID" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
source .registry.env
|
||||||
|
echo "Using DEPLOYER_LRN: $DEPLOYER_LRN"
|
||||||
|
|
||||||
|
echo "Deployment record ID: $DEPLOYMENT_RECORD_ID"
|
||||||
|
|
||||||
|
# Generate application-deployment-removal-request.yml
|
||||||
|
REMOVAL_REQUEST_RECORD_FILE=./records/application-deployment-removal-request.yml
|
||||||
|
|
||||||
|
cat > $REMOVAL_REQUEST_RECORD_FILE <<EOF
|
||||||
|
record:
|
||||||
|
deployer: $DEPLOYER_LRN
|
||||||
|
deployment: $DEPLOYMENT_RECORD_ID
|
||||||
|
type: ApplicationDeploymentRemovalRequest
|
||||||
|
version: 1.0.0
|
||||||
|
EOF
|
||||||
|
|
||||||
|
CONFIG_FILE=config.yml
|
||||||
|
|
||||||
|
sleep 2
|
||||||
|
REMOVAL_REQUEST_ID=$(laconic -c $CONFIG_FILE registry record publish --filename $REMOVAL_REQUEST_RECORD_FILE | jq -r '.id')
|
||||||
|
echo "ApplicationDeploymentRemovalRequest published"
|
||||||
|
echo $REMOVAL_REQUEST_ID
|
||||||
|
|
||||||
|
# Deployment checks
|
||||||
|
RETRY_INTERVAL=30
|
||||||
|
MAX_RETRIES=20
|
||||||
|
|
||||||
|
# Check that an ApplicationDeploymentRemovalRecord is published
|
||||||
|
retry_count=0
|
||||||
|
while true; do
|
||||||
|
removal_records_response=$(laconic -c $CONFIG_FILE registry record list --type ApplicationDeploymentRemovalRecord --all request $REMOVAL_REQUEST_ID)
|
||||||
|
len_removal_records=$(echo $removal_records_response | jq 'length')
|
||||||
|
|
||||||
|
# Check if number of records returned is 0
|
||||||
|
if [ $len_removal_records -eq 0 ]; then
|
||||||
|
# Check if retries are exhausted
|
||||||
|
if [ $retry_count -eq $MAX_RETRIES ]; then
|
||||||
|
echo "Retries exhausted"
|
||||||
|
echo "ApplicationDeploymentRemovalRecord for deployment removal request $REMOVAL_REQUEST_ID not found"
|
||||||
|
exit 1
|
||||||
|
else
|
||||||
|
echo "ApplicationDeploymentRemovalRecord not found, retrying in $RETRY_INTERVAL sec..."
|
||||||
|
sleep $RETRY_INTERVAL
|
||||||
|
retry_count=$((retry_count+1))
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "ApplicationDeploymentRemovalRecord found"
|
||||||
|
REMOVAL_RECORD_ID=$(echo $removal_records_response | jq -r '.[0].id')
|
||||||
|
echo $REMOVAL_RECORD_ID
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "Deployment removal successful"
|
5
next-env.d.ts
vendored
Normal file
5
next-env.d.ts
vendored
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
/// <reference types="next" />
|
||||||
|
/// <reference types="next/image-types/global" />
|
||||||
|
|
||||||
|
// NOTE: This file should not be edited
|
||||||
|
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
@ -1,6 +1,6 @@
|
|||||||
import type { NextConfig } from "next";
|
/** @type {import('next').NextConfig} */
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig = {
|
||||||
/* config options here */
|
/* config options here */
|
||||||
typescript: {
|
typescript: {
|
||||||
// !! WARN !!
|
// !! WARN !!
|
1024
package-lock.json
generated
1024
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"name": "atom-deploy",
|
"name": "gor-deploy",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@ -11,8 +11,11 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@cerc-io/registry-sdk": "^0.2.11",
|
"@cerc-io/registry-sdk": "^0.2.11",
|
||||||
"@cosmjs/stargate": "^0.32.3",
|
"@cosmjs/stargate": "^0.32.3",
|
||||||
"@keplr-wallet/types": "^0.12.71",
|
"@solana/spl-token": "^0.4.13",
|
||||||
|
"@solana/web3.js": "^1.98.2",
|
||||||
"axios": "^1.6.8",
|
"axios": "^1.6.8",
|
||||||
|
"big.js": "^6.2.2",
|
||||||
|
"bn.js": "^5.2.2",
|
||||||
"next": "15.3.1",
|
"next": "15.3.1",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0"
|
"react-dom": "^19.0.0"
|
||||||
|
@ -1,120 +1,25 @@
|
|||||||
|
import BN from 'bn.js';
|
||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { Account, Registry, parseGasAndFees } from '@cerc-io/registry-sdk';
|
|
||||||
import { GasPrice } from '@cosmjs/stargate';
|
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
|
import assert from 'assert';
|
||||||
|
|
||||||
|
import { GasPrice } from '@cosmjs/stargate';
|
||||||
|
import { Connection } from '@solana/web3.js';
|
||||||
|
import { DENOM as ALNT_DENOM } from '@cerc-io/registry-sdk';
|
||||||
|
|
||||||
|
import { verifyUnusedSolanaPayment } from '@/utils/solanaVerify';
|
||||||
|
import { transferLNTTokens } from '@/services/laconicTransfer';
|
||||||
|
import { getRegistry, getRegistryConfig } from '@/config';
|
||||||
|
|
||||||
|
assert(process.env.NEXT_PUBLIC_SOLANA_RPC_URL, 'SOLANA_RPC_URL is required');
|
||||||
|
const SOLANA_RPC_URL = process.env.NEXT_PUBLIC_SOLANA_RPC_URL;
|
||||||
|
|
||||||
|
// Use CAIP convention for chain ID: namespace + reference
|
||||||
|
const SOLANA_CHAIN_ID = 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp'; // Solana mainnet
|
||||||
|
|
||||||
// Sleep helper function
|
// Sleep helper function
|
||||||
const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
|
const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
|
||||||
|
|
||||||
// ATOM payment verification function
|
|
||||||
const verifyAtomPayment = async (txHash: string): Promise<{
|
|
||||||
valid: boolean,
|
|
||||||
reason?: string,
|
|
||||||
amount?: string,
|
|
||||||
sender?: string
|
|
||||||
}> => {
|
|
||||||
try {
|
|
||||||
const apiEndpoint = process.env.NEXT_PUBLIC_COSMOS_API_URL;
|
|
||||||
const recipientAddress = process.env.NEXT_PUBLIC_RECIPIENT_ADDRESS;
|
|
||||||
const minPaymentUAtom = '100000'; // 0.1 ATOM in uatom
|
|
||||||
|
|
||||||
if (!apiEndpoint) {
|
|
||||||
return {
|
|
||||||
valid: false,
|
|
||||||
reason: 'ATOM API endpoint not configured'
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!recipientAddress) {
|
|
||||||
return {
|
|
||||||
valid: false,
|
|
||||||
reason: 'ATOM recipient address not configured'
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch transaction from the ATOM API endpoint
|
|
||||||
const response = await axios.get(`${apiEndpoint}/cosmos/tx/v1beta1/txs/${txHash}`);
|
|
||||||
|
|
||||||
if (!response.data || !response.data.tx || !response.data.tx_response) {
|
|
||||||
return {
|
|
||||||
valid: false,
|
|
||||||
reason: 'Invalid transaction data from API endpoint'
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if transaction was successful
|
|
||||||
const txResponse = response.data.tx_response;
|
|
||||||
if (txResponse.code !== 0) {
|
|
||||||
return {
|
|
||||||
valid: false,
|
|
||||||
reason: `Transaction failed with code ${txResponse.code}: ${txResponse.raw_log}`
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check transaction timestamp (5-minute window)
|
|
||||||
const txTimestamp = new Date(txResponse.timestamp);
|
|
||||||
const now = new Date();
|
|
||||||
const timeDiffMs = now.getTime() - txTimestamp.getTime();
|
|
||||||
const timeWindowMs = 5 * 60 * 1000; // 5 minutes
|
|
||||||
|
|
||||||
if (timeDiffMs > timeWindowMs) {
|
|
||||||
return {
|
|
||||||
valid: false,
|
|
||||||
reason: `Transaction is older than 5 minutes (${Math.round(timeDiffMs / 60000)} minutes old)`
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract the payment details
|
|
||||||
const tx = response.data.tx;
|
|
||||||
let foundValidPayment = false;
|
|
||||||
let paymentAmountUAtom = '';
|
|
||||||
let sender = '';
|
|
||||||
|
|
||||||
// Get the sender address from the first signer
|
|
||||||
if (tx.auth_info && tx.auth_info.signer_infos && tx.auth_info.signer_infos.length > 0) {
|
|
||||||
sender = tx.auth_info.signer_infos[0].public_key.address || '';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Find the send message in the transaction
|
|
||||||
for (const msg of tx.body.messages) {
|
|
||||||
if (msg['@type'] === '/cosmos.bank.v1beta1.MsgSend') {
|
|
||||||
if (msg.to_address === recipientAddress) {
|
|
||||||
for (const coin of msg.amount) {
|
|
||||||
if (coin.denom === 'uatom') {
|
|
||||||
// Get the amount in uatom
|
|
||||||
paymentAmountUAtom = coin.amount;
|
|
||||||
|
|
||||||
if (parseInt(paymentAmountUAtom) >= parseInt(minPaymentUAtom)) {
|
|
||||||
foundValidPayment = true;
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!foundValidPayment) {
|
|
||||||
return {
|
|
||||||
valid: false,
|
|
||||||
reason: `Payment amount (${paymentAmountUAtom || '0'}uatom) is less than required (${minPaymentUAtom}uatom) or not sent to the correct address (${recipientAddress})`
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
valid: true,
|
|
||||||
amount: `${paymentAmountUAtom}uatom`,
|
|
||||||
sender
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error verifying ATOM payment:', error);
|
|
||||||
return {
|
|
||||||
valid: false,
|
|
||||||
reason: `Failed to verify transaction: ${error.message || 'Unknown error'}`
|
|
||||||
};
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Extract repo name from URL
|
// Extract repo name from URL
|
||||||
const extractRepoInfo = (url: string): { repoName: string, repoUrl: string, provider: string } => {
|
const extractRepoInfo = (url: string): { repoName: string, repoUrl: string, provider: string } => {
|
||||||
try {
|
try {
|
||||||
@ -202,7 +107,7 @@ const fetchLatestCommitHash = async (repoUrl: string, provider: string): Promise
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Registry transaction retry helper
|
// Registry transaction retry helper
|
||||||
const registryTransactionWithRetry = async (
|
export const registryTransactionWithRetry = async (
|
||||||
txFn: () => Promise<unknown>,
|
txFn: () => Promise<unknown>,
|
||||||
maxRetries = 3,
|
maxRetries = 3,
|
||||||
delay = 1000
|
delay = 1000
|
||||||
@ -225,15 +130,37 @@ const registryTransactionWithRetry = async (
|
|||||||
throw lastError;
|
throw lastError;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let connection: Connection;
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
|
if (!connection) {
|
||||||
|
connection = new Connection(SOLANA_RPC_URL);
|
||||||
|
}
|
||||||
|
|
||||||
// First check if the request body is valid JSON
|
// First check if the request body is valid JSON
|
||||||
let url, txHash;
|
let url, txHash, senderPublicKey;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
url = body.url;
|
url = body.url;
|
||||||
txHash = body.txHash;
|
txHash = body.txHash;
|
||||||
|
|
||||||
|
const tx = await connection.getParsedTransaction(txHash, 'confirmed');
|
||||||
|
if (!tx) {
|
||||||
|
console.error("Transaction not found.");
|
||||||
|
return NextResponse.json({
|
||||||
|
status: 'error',
|
||||||
|
message: 'Invalid tx hash'
|
||||||
|
}, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const signerKeys = tx.transaction.message.accountKeys
|
||||||
|
.filter(k => k.signer)
|
||||||
|
.map(k => k.pubkey.toBase58());
|
||||||
|
|
||||||
|
senderPublicKey = signerKeys[0];
|
||||||
|
|
||||||
if (!url || !txHash) {
|
if (!url || !txHash) {
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
status: 'error',
|
status: 'error',
|
||||||
@ -241,40 +168,59 @@ export async function POST(request: NextRequest) {
|
|||||||
}, { status: 400 });
|
}, { status: 400 });
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
status: 'error',
|
status: 'error',
|
||||||
message: 'Invalid JSON in request body'
|
message: 'Invalid JSON in request body'
|
||||||
}, { status: 400 });
|
}, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// First, verify the ATOM payment before doing anything else
|
// Verify Solana payment
|
||||||
console.log('Step 0: Verifying ATOM payment...');
|
console.log('Step 0: Verifying Solana token payment...');
|
||||||
const paymentVerificationResult = await verifyAtomPayment(txHash);
|
const paymentAmount = parseInt(process.env.NEXT_PUBLIC_SOLANA_PAYMENT_AMOUNT!);
|
||||||
|
const tokenAmount = new BN(paymentAmount);
|
||||||
|
const solanaPaymentResult = await verifyUnusedSolanaPayment(connection, txHash, tokenAmount);
|
||||||
|
|
||||||
if (!paymentVerificationResult.valid) {
|
if (!solanaPaymentResult.valid) {
|
||||||
console.error('ATOM payment verification failed:', paymentVerificationResult.reason);
|
console.error('Solana token payment verification failed:', solanaPaymentResult.reason);
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
status: 'error',
|
status: 'error',
|
||||||
message: `Payment verification failed: ${paymentVerificationResult.reason}`
|
message: `Payment verification failed: ${solanaPaymentResult.reason}`
|
||||||
}, { status: 400 });
|
}, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('ATOM payment verified successfully:', {
|
console.log('Solana token payment verified successfully:', {
|
||||||
amount: paymentVerificationResult.amount,
|
amount: solanaPaymentResult.amount,
|
||||||
sender: paymentVerificationResult.sender
|
sender: solanaPaymentResult.sender
|
||||||
});
|
});
|
||||||
|
|
||||||
// Validate required environment variables
|
// Perform LNT transfer after payment verification
|
||||||
|
console.log('Step 0.5: Performing LNT transfer from prefilled account to service provider...');
|
||||||
|
const lntTransferResult = await transferLNTTokens();
|
||||||
|
|
||||||
|
if (!lntTransferResult.success) {
|
||||||
|
console.error('LNT transfer failed:', lntTransferResult.error);
|
||||||
|
return NextResponse.json({
|
||||||
|
status: 'error',
|
||||||
|
message: `LNT transfer failed: ${lntTransferResult.error}`
|
||||||
|
}, { status: 500 });
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('LNT transfer completed:', lntTransferResult.transactionHash);
|
||||||
|
const finalTxHash = lntTransferResult.transactionHash!; // Use LNT transfer hash for registry
|
||||||
|
|
||||||
|
// Validate required environment variables for Solana payments
|
||||||
const requiredEnvVars = [
|
const requiredEnvVars = [
|
||||||
'REGISTRY_CHAIN_ID',
|
'REGISTRY_CHAIN_ID',
|
||||||
'REGISTRY_GQL_ENDPOINT',
|
'REGISTRY_GQL_ENDPOINT',
|
||||||
'REGISTRY_RPC_ENDPOINT',
|
'REGISTRY_RPC_ENDPOINT',
|
||||||
'REGISTRY_BOND_ID',
|
'REGISTRY_BOND_ID',
|
||||||
'REGISTRY_AUTHORITY',
|
'REGISTRY_AUTHORITY',
|
||||||
'REGISTRY_USER_KEY',
|
'REGISTRY_USER_KEY', // This is the same as the prefilled account for LNT transfers
|
||||||
'DEPLOYER_LRN',
|
'DEPLOYER_LRN',
|
||||||
'NEXT_PUBLIC_RECIPIENT_ADDRESS',
|
'NEXT_PUBLIC_SOLANA_TOKEN_MINT_ADDRESS',
|
||||||
'NEXT_PUBLIC_COSMOS_API_URL'
|
'NEXT_PUBLIC_SOLANA_TOKEN_RECIPIENT_ADDRESS'
|
||||||
];
|
];
|
||||||
|
|
||||||
for (const envVar of requiredEnvVars) {
|
for (const envVar of requiredEnvVars) {
|
||||||
@ -302,17 +248,9 @@ export async function POST(request: NextRequest) {
|
|||||||
// Sanitize the app name to ensure it's DNS-compatible (only alphanumeric and dashes)
|
// Sanitize the app name to ensure it's DNS-compatible (only alphanumeric and dashes)
|
||||||
const sanitizedAppName = appName.replace(/[^a-zA-Z0-9-]/g, '-').toLowerCase();
|
const sanitizedAppName = appName.replace(/[^a-zA-Z0-9-]/g, '-').toLowerCase();
|
||||||
|
|
||||||
// Generate a random salt (6 alphanumeric characters) to prevent name collisions
|
// Create DNS name in format: app_name-shortcommithash
|
||||||
const generateSalt = (): string => {
|
const dnsName = `${sanitizedAppName}-${shortHash}`;
|
||||||
const chars = 'abcdefghijklmnopqrstuvwxyz0123456789';
|
console.log(`DNS name: ${dnsName} (sanitized from: ${appName})`);
|
||||||
return Array.from({ length: 6 }, () => chars.charAt(Math.floor(Math.random() * chars.length))).join('');
|
|
||||||
};
|
|
||||||
const salt = generateSalt();
|
|
||||||
console.log(`Generated salt: ${salt}`);
|
|
||||||
|
|
||||||
// Create DNS name in format: app_name-shortcommithash-salt
|
|
||||||
const dnsName = `${sanitizedAppName}-${shortHash}-${salt}`;
|
|
||||||
console.log(`DNS name with salt: ${dnsName} (sanitized from: ${appName})`);
|
|
||||||
|
|
||||||
// Ensure the DNS name doesn't have consecutive dashes or start/end with a dash
|
// Ensure the DNS name doesn't have consecutive dashes or start/end with a dash
|
||||||
let cleanDnsName = dnsName
|
let cleanDnsName = dnsName
|
||||||
@ -321,10 +259,9 @@ export async function POST(request: NextRequest) {
|
|||||||
|
|
||||||
// Ensure DNS name is valid (63 chars max per label, all lowercase, starts with a letter)
|
// Ensure DNS name is valid (63 chars max per label, all lowercase, starts with a letter)
|
||||||
if (cleanDnsName.length > 63) {
|
if (cleanDnsName.length > 63) {
|
||||||
// If too long, truncate but preserve both the commit hash and salt parts
|
// If too long, truncate but preserve both the commit hash
|
||||||
const suffixPart = `-${shortHash}-${salt}`;
|
const maxAppNameLength = 63 - shortHash.length;
|
||||||
const maxAppNameLength = 63 - suffixPart.length;
|
cleanDnsName = sanitizedAppName.substring(0, maxAppNameLength) + shortHash;
|
||||||
cleanDnsName = sanitizedAppName.substring(0, maxAppNameLength) + suffixPart;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// If the DNS name ended up empty (unlikely) or doesn't start with a letter (possible),
|
// If the DNS name ended up empty (unlikely) or doesn't start with a letter (possible),
|
||||||
@ -333,22 +270,10 @@ export async function POST(request: NextRequest) {
|
|||||||
cleanDnsName = `app-${cleanDnsName}`;
|
cleanDnsName = `app-${cleanDnsName}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`Final DNS name with salt: ${cleanDnsName}`);
|
console.log(`Final DNS name: ${cleanDnsName}`);
|
||||||
|
|
||||||
// Set up Registry config
|
// Set up Registry config
|
||||||
const config = {
|
const config = getRegistryConfig()
|
||||||
chainId: process.env.REGISTRY_CHAIN_ID!,
|
|
||||||
rpcEndpoint: process.env.REGISTRY_RPC_ENDPOINT!,
|
|
||||||
gqlEndpoint: process.env.REGISTRY_GQL_ENDPOINT!,
|
|
||||||
bondId: process.env.REGISTRY_BOND_ID!,
|
|
||||||
authority: process.env.REGISTRY_AUTHORITY!,
|
|
||||||
privateKey: process.env.REGISTRY_USER_KEY!,
|
|
||||||
fee: {
|
|
||||||
gas: process.env.REGISTRY_GAS || '900000',
|
|
||||||
fees: process.env.REGISTRY_FEES || '900000alnt',
|
|
||||||
gasPrice: '0.001alnt', // Hardcoded valid gas price string with denomination
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
console.log('Registry config:', {
|
console.log('Registry config:', {
|
||||||
...config,
|
...config,
|
||||||
@ -358,18 +283,13 @@ export async function POST(request: NextRequest) {
|
|||||||
const deployerLrn = process.env.DEPLOYER_LRN!;
|
const deployerLrn = process.env.DEPLOYER_LRN!;
|
||||||
|
|
||||||
// Create Registry client instance
|
// Create Registry client instance
|
||||||
const gasPrice = GasPrice.fromString('0.001alnt');
|
const gasPrice = GasPrice.fromString(config.fee.gasPrice + ALNT_DENOM);
|
||||||
console.log('Using manual gas price:', gasPrice);
|
console.log('Using manual gas price:', gasPrice);
|
||||||
|
|
||||||
const registry = new Registry(
|
const registry = getRegistry()
|
||||||
config.gqlEndpoint,
|
|
||||||
config.rpcEndpoint,
|
|
||||||
{ chainId: config.chainId, gasPrice }
|
|
||||||
);
|
|
||||||
|
|
||||||
// Create LRN for the application with commit hash and salt
|
// Create LRN for the application with commit hash
|
||||||
// We already have the salt from earlier, so we use it directly
|
const lrn = `lrn://${config.authority}/applications/${appName}-${shortHash}`;
|
||||||
const lrn = `lrn://${config.authority}/applications/${appName}-${shortHash}-${salt}`;
|
|
||||||
|
|
||||||
// Get current timestamp for the meta note
|
// Get current timestamp for the meta note
|
||||||
const timestamp = new Date().toUTCString();
|
const timestamp = new Date().toUTCString();
|
||||||
@ -378,7 +298,7 @@ export async function POST(request: NextRequest) {
|
|||||||
console.log('Step 1: Publishing ApplicationRecord...');
|
console.log('Step 1: Publishing ApplicationRecord...');
|
||||||
const applicationRecord = {
|
const applicationRecord = {
|
||||||
type: 'ApplicationRecord',
|
type: 'ApplicationRecord',
|
||||||
name: `${appName}-${shortHash}-${salt}`, // Include commit hash and salt in the record name
|
name: `${appName}-${shortHash}`, // Include commit hash in the record name
|
||||||
version: '1.0.0',
|
version: '1.0.0',
|
||||||
app_type: 'webapp',
|
app_type: 'webapp',
|
||||||
repository: [repoUrl],
|
repository: [repoUrl],
|
||||||
@ -386,12 +306,6 @@ export async function POST(request: NextRequest) {
|
|||||||
app_version: '0.0.1'
|
app_version: '0.0.1'
|
||||||
};
|
};
|
||||||
|
|
||||||
// Create fee for transaction directly
|
|
||||||
const fee = {
|
|
||||||
amount: [{ denom: 'alnt', amount: process.env.REGISTRY_FEES?.replace('alnt', '') || '900000' }],
|
|
||||||
gas: process.env.REGISTRY_GAS || '900000',
|
|
||||||
};
|
|
||||||
|
|
||||||
console.log('Application record data:', applicationRecord);
|
console.log('Application record data:', applicationRecord);
|
||||||
|
|
||||||
// Publish the application record
|
// Publish the application record
|
||||||
@ -405,7 +319,6 @@ export async function POST(request: NextRequest) {
|
|||||||
bondId: config.bondId,
|
bondId: config.bondId,
|
||||||
},
|
},
|
||||||
config.privateKey,
|
config.privateKey,
|
||||||
fee
|
|
||||||
)
|
)
|
||||||
) as { id?: string };
|
) as { id?: string };
|
||||||
|
|
||||||
@ -437,7 +350,6 @@ export async function POST(request: NextRequest) {
|
|||||||
lrn
|
lrn
|
||||||
},
|
},
|
||||||
config.privateKey,
|
config.privateKey,
|
||||||
fee
|
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
console.log(`Set name mapping: ${lrn} -> ${applicationRecordId}`);
|
console.log(`Set name mapping: ${lrn} -> ${applicationRecordId}`);
|
||||||
@ -450,7 +362,6 @@ export async function POST(request: NextRequest) {
|
|||||||
lrn: `${lrn}@${fullHash}`
|
lrn: `${lrn}@${fullHash}`
|
||||||
},
|
},
|
||||||
config.privateKey,
|
config.privateKey,
|
||||||
fee
|
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
console.log(`Set name mapping: ${lrn}@${fullHash} -> ${applicationRecordId}`);
|
console.log(`Set name mapping: ${lrn}@${fullHash} -> ${applicationRecordId}`);
|
||||||
@ -468,21 +379,25 @@ export async function POST(request: NextRequest) {
|
|||||||
const deploymentRequestData = {
|
const deploymentRequestData = {
|
||||||
type: 'ApplicationDeploymentRequest',
|
type: 'ApplicationDeploymentRequest',
|
||||||
version: '1.0.0',
|
version: '1.0.0',
|
||||||
name: `${appName}-${shortHash}-${salt}`, // Update name to match application record
|
name: `${appName}-${shortHash}`, // Update name to match application record
|
||||||
application: lrn, // LRN already includes commit hash and salt
|
application: lrn, // LRN already includes commit hash
|
||||||
deployer: deployerLrn,
|
deployer: deployerLrn,
|
||||||
dns: cleanDnsName,
|
dns: cleanDnsName,
|
||||||
config: {
|
config: {
|
||||||
env: {
|
// TODO: Add feature to set env config for app
|
||||||
LACONIC_HOSTED_CONFIG_laconicd_chain_id: process.env.REGISTRY_CHAIN_ID || 'laconic-testnet-2'
|
env: {}
|
||||||
}
|
|
||||||
},
|
},
|
||||||
meta: {
|
meta: {
|
||||||
note: `Added via ATOM-Deploy @ ${timestamp}`,
|
note: `Added via ${process.env.NEXT_PUBLIC_SOLANA_TOKEN_SYMBOL}-Deploy @ ${timestamp}`,
|
||||||
repository: repoUrl,
|
repository: repoUrl,
|
||||||
repository_ref: fullHash,
|
repository_ref: fullHash,
|
||||||
|
external_payment: {
|
||||||
|
chain_id: SOLANA_CHAIN_ID,
|
||||||
|
tx_hash: txHash,
|
||||||
|
pubkey: senderPublicKey
|
||||||
|
}
|
||||||
},
|
},
|
||||||
payment: txHash,
|
payment: finalTxHash,
|
||||||
};
|
};
|
||||||
|
|
||||||
console.log('Deployment request data:', deploymentRequestData);
|
console.log('Deployment request data:', deploymentRequestData);
|
||||||
@ -498,7 +413,6 @@ export async function POST(request: NextRequest) {
|
|||||||
bondId: config.bondId,
|
bondId: config.bondId,
|
||||||
},
|
},
|
||||||
config.privateKey,
|
config.privateKey,
|
||||||
fee
|
|
||||||
)
|
)
|
||||||
) as { id?: string };
|
) as { id?: string };
|
||||||
|
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { Geist, Geist_Mono } from "next/font/google";
|
import { Geist, Geist_Mono } from "next/font/google";
|
||||||
|
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
import ErrorBoundaryWrapper from "../components/ErrorBoundaryWrapper";
|
import ErrorBoundaryWrapper from "../components/ErrorBoundaryWrapper";
|
||||||
|
|
||||||
@ -14,8 +15,8 @@ const geistMono = Geist_Mono({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Deploy Frontends using ATOM and Laconic",
|
title: `Deploy Frontends using ${process.env.NEXT_PUBLIC_SOLANA_TOKEN_SYMBOL} and Laconic`,
|
||||||
description: "Deploy URLs to Laconic Registry using ATOM payments",
|
description: `Deploy URLs to Laconic Registry using ${process.env.NEXT_PUBLIC_SOLANA_TOKEN_SYMBOL} payments`,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function RootLayout({
|
export default function RootLayout({
|
||||||
|
130
src/app/page.tsx
130
src/app/page.tsx
@ -1,17 +1,24 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useCallback, useState } from 'react';
|
||||||
// Dynamically import Keplr component to avoid SSR issues with browser APIs
|
|
||||||
const KeplrConnect = dynamic(() => import('@/components/KeplrConnect'), { ssr: false });
|
|
||||||
import URLForm from '@/components/URLForm';
|
|
||||||
// Dynamically import PaymentModal component to avoid SSR issues with browser APIs
|
|
||||||
const PaymentModal = dynamic(() => import('@/components/PaymentModal'), { ssr: false });
|
|
||||||
import StatusDisplay from '@/components/StatusDisplay';
|
|
||||||
import { createApplicationDeploymentRequest } from '@/services/registry';
|
|
||||||
import dynamic from 'next/dynamic';
|
import dynamic from 'next/dynamic';
|
||||||
|
|
||||||
|
import URLForm from '@/components/URLForm';
|
||||||
|
import StatusDisplay from '@/components/StatusDisplay';
|
||||||
|
import { createApplicationDeploymentRequest } from '@/services/registry';
|
||||||
|
import { SolanaWalletState } from '@/types';
|
||||||
|
|
||||||
|
// Dynamically import components to avoid SSR issues with browser APIs
|
||||||
|
const SolanaConnect = dynamic(() => import('@/components/SolanaConnect'), { ssr: false });
|
||||||
|
const PaymentModal = dynamic(() => import('@/components/PaymentModal'), { ssr: false });
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
const [walletAddress, setWalletAddress] = useState<string | null>(null);
|
const [showWalletConnection, setShowWalletConnection] = useState(false);
|
||||||
|
const [solanaWalletState, setSolanaWalletState] = useState<SolanaWalletState>({
|
||||||
|
connected: false,
|
||||||
|
publicKey: null,
|
||||||
|
walletType: null
|
||||||
|
});
|
||||||
const [url, setUrl] = useState<string | null>(null);
|
const [url, setUrl] = useState<string | null>(null);
|
||||||
const [showPaymentModal, setShowPaymentModal] = useState(false);
|
const [showPaymentModal, setShowPaymentModal] = useState(false);
|
||||||
const [status, setStatus] = useState<'idle' | 'creating' | 'success' | 'error'>('idle');
|
const [status, setStatus] = useState<'idle' | 'creating' | 'success' | 'error'>('idle');
|
||||||
@ -26,8 +33,8 @@ export default function Home() {
|
|||||||
const [shortCommitHash, setShortCommitHash] = useState<string | null>(null);
|
const [shortCommitHash, setShortCommitHash] = useState<string | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
const handleConnect = (address: string) => {
|
const handleConnectWallet = () => {
|
||||||
setWalletAddress(address);
|
setShowWalletConnection(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleUrlSubmit = (submittedUrl: string) => {
|
const handleUrlSubmit = (submittedUrl: string) => {
|
||||||
@ -35,50 +42,52 @@ export default function Home() {
|
|||||||
setShowPaymentModal(true);
|
setShowPaymentModal(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handlePaymentComplete = async (hash: string) => {
|
const handlePaymentComplete = useCallback(async (hash: string) => {
|
||||||
|
if (!solanaWalletState.publicKey || !url) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
setTxHash(hash);
|
setTxHash(hash);
|
||||||
setShowPaymentModal(false);
|
setShowPaymentModal(false);
|
||||||
setStatus('creating');
|
setStatus('creating');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Create the Laconic Registry record (payment verification is done in the API)
|
// Create the Laconic Registry record (payment verification is done in the API)
|
||||||
if (url) {
|
const result = await createApplicationDeploymentRequest(url, hash);
|
||||||
const result = await createApplicationDeploymentRequest(url, hash);
|
|
||||||
|
|
||||||
if (result.status === 'success') {
|
if (result.status === 'success') {
|
||||||
setRecordId(result.id);
|
setRecordId(result.id);
|
||||||
if (result.applicationRecordId) {
|
if (result.applicationRecordId) {
|
||||||
setAppRecordId(result.applicationRecordId);
|
setAppRecordId(result.applicationRecordId);
|
||||||
}
|
|
||||||
if (result.lrn) {
|
|
||||||
setLrn(result.lrn);
|
|
||||||
}
|
|
||||||
if (result.dns) {
|
|
||||||
setDns(result.dns);
|
|
||||||
}
|
|
||||||
if (result.appName) {
|
|
||||||
setAppName(result.appName);
|
|
||||||
}
|
|
||||||
if (result.repoUrl) {
|
|
||||||
setRepoUrl(result.repoUrl);
|
|
||||||
}
|
|
||||||
if (result.commitHash) {
|
|
||||||
setCommitHash(result.commitHash);
|
|
||||||
}
|
|
||||||
if (result.shortCommitHash) {
|
|
||||||
setShortCommitHash(result.shortCommitHash);
|
|
||||||
}
|
|
||||||
setStatus('success');
|
|
||||||
} else {
|
|
||||||
setStatus('error');
|
|
||||||
setError(result.message || 'Failed to create record in Laconic Registry');
|
|
||||||
}
|
}
|
||||||
|
if (result.lrn) {
|
||||||
|
setLrn(result.lrn);
|
||||||
|
}
|
||||||
|
if (result.dns) {
|
||||||
|
setDns(result.dns);
|
||||||
|
}
|
||||||
|
if (result.appName) {
|
||||||
|
setAppName(result.appName);
|
||||||
|
}
|
||||||
|
if (result.repoUrl) {
|
||||||
|
setRepoUrl(result.repoUrl);
|
||||||
|
}
|
||||||
|
if (result.commitHash) {
|
||||||
|
setCommitHash(result.commitHash);
|
||||||
|
}
|
||||||
|
if (result.shortCommitHash) {
|
||||||
|
setShortCommitHash(result.shortCommitHash);
|
||||||
|
}
|
||||||
|
setStatus('success');
|
||||||
|
} else {
|
||||||
|
setStatus('error');
|
||||||
|
setError(result.message || 'Failed to create record in Laconic Registry');
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setStatus('error');
|
setStatus('error');
|
||||||
setError(error instanceof Error ? error.message : 'An unknown error occurred');
|
setError(error instanceof Error ? error.message : 'An unknown error occurred');
|
||||||
}
|
}
|
||||||
};
|
}, [solanaWalletState, url])
|
||||||
|
|
||||||
const handleClosePaymentModal = () => {
|
const handleClosePaymentModal = () => {
|
||||||
setShowPaymentModal(false);
|
setShowPaymentModal(false);
|
||||||
@ -89,7 +98,7 @@ export default function Home() {
|
|||||||
<div style={{ background: 'var(--card-bg)', borderColor: 'var(--card-border)' }}
|
<div style={{ background: 'var(--card-bg)', borderColor: 'var(--card-border)' }}
|
||||||
className="max-w-xl w-full p-8 rounded-xl shadow-lg border">
|
className="max-w-xl w-full p-8 rounded-xl shadow-lg border">
|
||||||
<h1 className="text-2xl font-bold mb-8 text-center" style={{ color: 'var(--foreground)' }}>
|
<h1 className="text-2xl font-bold mb-8 text-center" style={{ color: 'var(--foreground)' }}>
|
||||||
Deploy Frontends with ATOM and Laconic
|
Deploy Frontends with {process.env.NEXT_PUBLIC_SOLANA_TOKEN_SYMBOL} + Laconic
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
<div className="mb-10 p-6 rounded-lg" style={{ background: 'var(--muted-light)', borderLeft: '4px solid var(--primary)' }}>
|
<div className="mb-10 p-6 rounded-lg" style={{ background: 'var(--muted-light)', borderLeft: '4px solid var(--primary)' }}>
|
||||||
@ -98,10 +107,34 @@ export default function Home() {
|
|||||||
style={{ background: 'var(--primary)', color: 'var(--primary-foreground)' }}>1</span>
|
style={{ background: 'var(--primary)', color: 'var(--primary-foreground)' }}>1</span>
|
||||||
Connect Your Wallet
|
Connect Your Wallet
|
||||||
</h2>
|
</h2>
|
||||||
<KeplrConnect onConnect={handleConnect} />
|
{!showWalletConnection ? (
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="mb-4" style={{ color: 'var(--muted-foreground)' }}>
|
||||||
|
Payment method: <span className="font-semibold" style={{ color: 'var(--foreground)' }}>
|
||||||
|
{process.env.NEXT_PUBLIC_SOLANA_TOKEN_SYMBOL} (Solana)
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={handleConnectWallet}
|
||||||
|
className="px-6 py-2 rounded-lg font-medium transition-colors"
|
||||||
|
style={{
|
||||||
|
background: 'var(--primary)',
|
||||||
|
color: 'var(--primary-foreground)',
|
||||||
|
border: 'none'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Connect Solana Wallet
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<SolanaConnect onConnect={(walletState) => setSolanaWalletState(walletState)} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<div className="mb-8 p-6 rounded-lg" style={{
|
||||||
<div className="mb-8 p-6 rounded-lg" style={{ background: 'var(--muted-light)', borderLeft: '4px solid var(--primary)', opacity: walletAddress ? '1' : '0.6' }}>
|
background: 'var(--muted-light)',
|
||||||
|
borderLeft: '4px solid var(--primary)',
|
||||||
|
opacity: solanaWalletState.connected ? '1' : '0.6'
|
||||||
|
}}>
|
||||||
<h2 className="text-lg font-semibold mb-4 flex items-center">
|
<h2 className="text-lg font-semibold mb-4 flex items-center">
|
||||||
<span className="inline-flex items-center justify-center mr-3 w-7 h-7 rounded-full text-sm font-bold"
|
<span className="inline-flex items-center justify-center mr-3 w-7 h-7 rounded-full text-sm font-bold"
|
||||||
style={{ background: 'var(--primary)', color: 'var(--primary-foreground)' }}>2</span>
|
style={{ background: 'var(--primary)', color: 'var(--primary-foreground)' }}>2</span>
|
||||||
@ -109,7 +142,7 @@ export default function Home() {
|
|||||||
</h2>
|
</h2>
|
||||||
<URLForm
|
<URLForm
|
||||||
onSubmit={handleUrlSubmit}
|
onSubmit={handleUrlSubmit}
|
||||||
disabled={!walletAddress || status === 'creating'}
|
disabled={!solanaWalletState.connected || status === 'creating'}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -137,12 +170,13 @@ export default function Home() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{showPaymentModal && walletAddress && url && (
|
{showPaymentModal && url && solanaWalletState.connected && (
|
||||||
<PaymentModal
|
<PaymentModal
|
||||||
isOpen={showPaymentModal}
|
isOpen={showPaymentModal}
|
||||||
onClose={handleClosePaymentModal}
|
onClose={handleClosePaymentModal}
|
||||||
url={url}
|
url={url}
|
||||||
onPaymentComplete={handlePaymentComplete}
|
onPaymentComplete={handlePaymentComplete}
|
||||||
|
walletState={solanaWalletState}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</main>
|
</main>
|
||||||
|
@ -1,74 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
|
||||||
import { connectKeplr } from '@/services/keplr';
|
|
||||||
|
|
||||||
interface KeplrConnectProps {
|
|
||||||
onConnect: (address: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function KeplrConnect({ onConnect }: KeplrConnectProps) {
|
|
||||||
const [connecting, setConnecting] = useState(false);
|
|
||||||
const [address, setAddress] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const handleConnect = async () => {
|
|
||||||
setConnecting(true);
|
|
||||||
try {
|
|
||||||
const userAddress = await connectKeplr();
|
|
||||||
if (userAddress) {
|
|
||||||
setAddress(userAddress);
|
|
||||||
onConnect(userAddress);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to connect to Keplr:', error);
|
|
||||||
} finally {
|
|
||||||
setConnecting(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
// Check if Keplr is available
|
|
||||||
if (typeof window !== 'undefined' && window.keplr) {
|
|
||||||
// Auto-connect on page load
|
|
||||||
handleConnect();
|
|
||||||
}
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col items-center p-4 rounded-lg">
|
|
||||||
{address ? (
|
|
||||||
<div className="flex flex-col items-center w-full">
|
|
||||||
<div className="flex items-center mb-2">
|
|
||||||
<span className="w-3 h-3 rounded-full mr-2" style={{ backgroundColor: 'var(--success)' }}></span>
|
|
||||||
<p className="font-medium" style={{ color: 'var(--success)' }}>Connected</p>
|
|
||||||
</div>
|
|
||||||
<div className="w-full p-3 rounded-md" style={{ background: 'var(--card-bg)', border: '1px solid var(--card-border)' }}>
|
|
||||||
<p className="text-sm font-mono break-all text-center">{address}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<button
|
|
||||||
onClick={handleConnect}
|
|
||||||
disabled={connecting}
|
|
||||||
className="px-6 py-3 rounded-md w-full sm:w-auto transition-colors"
|
|
||||||
style={{
|
|
||||||
backgroundColor: connecting ? 'var(--muted)' : 'var(--primary)',
|
|
||||||
color: 'var(--primary-foreground)',
|
|
||||||
opacity: connecting ? '0.8' : '1',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-center">
|
|
||||||
{connecting && (
|
|
||||||
<svg className="animate-spin -ml-1 mr-3 h-5 w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
|
||||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
|
||||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
|
||||||
</svg>
|
|
||||||
)}
|
|
||||||
{connecting ? 'Connecting...' : 'Connect Keplr Wallet'}
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
@ -1,73 +1,75 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useCallback, useMemo, useState } from 'react';
|
||||||
import { sendAtomPayment } from '@/services/keplr';
|
import BN from 'bn.js';
|
||||||
|
// import assert from 'assert';
|
||||||
|
|
||||||
interface PaymentModalProps {
|
import { clusterApiUrl, Connection } from '@solana/web3.js';
|
||||||
isOpen: boolean;
|
|
||||||
onClose: () => void;
|
import { sendSolanaTokenPayment } from '@/services/solana';
|
||||||
url: string;
|
import { PaymentModalProps } from '@/types';
|
||||||
onPaymentComplete: (txHash: string) => void;
|
|
||||||
}
|
// assert(process.env.NEXT_PUBLIC_SOLANA_RPC_URL, 'SOLANA_RPC_URL is required');
|
||||||
|
// const SOLANA_RPC_URL = process.env.NEXT_PUBLIC_SOLANA_RPC_URL;
|
||||||
|
|
||||||
export default function PaymentModal({
|
export default function PaymentModal({
|
||||||
isOpen,
|
isOpen,
|
||||||
onClose,
|
onClose,
|
||||||
url,
|
url,
|
||||||
onPaymentComplete,
|
onPaymentComplete,
|
||||||
|
walletState,
|
||||||
}: PaymentModalProps) {
|
}: PaymentModalProps) {
|
||||||
const [amount, setAmount] = useState('0.01');
|
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
// Get recipient address from environment variables
|
const connection = useMemo(() => new Connection(clusterApiUrl("mainnet-beta")), []);
|
||||||
const recipientAddress = process.env.NEXT_PUBLIC_RECIPIENT_ADDRESS || 'cosmos1yourrealaddress';
|
// const connection = useMemo(() => new Connection(SOLANA_RPC_URL), [])
|
||||||
|
|
||||||
// Validate amount on change
|
// Get configuration from environment variables directly
|
||||||
const handleAmountChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const amount = parseInt(process.env.NEXT_PUBLIC_SOLANA_PAYMENT_AMOUNT!);
|
||||||
const value = e.target.value;
|
|
||||||
setAmount(value);
|
|
||||||
|
|
||||||
// Clear error when user types
|
const divisor = useMemo(() => {
|
||||||
if (error) {
|
const decimalsEnv = process.env.NEXT_PUBLIC_SOLANA_TOKEN_DECIMALS;
|
||||||
setError('');
|
const decimals = parseInt(decimalsEnv!, 10);
|
||||||
|
if (isNaN(decimals)) {
|
||||||
|
console.warn("Invalid NEXT_PUBLIC_SOLANA_TOKEN_DECIMALS; defaulting to 6.");
|
||||||
|
return 1e6;
|
||||||
}
|
}
|
||||||
};
|
return 10 ** decimals;
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handlePayment = async () => {
|
const recipientAddress = process.env.NEXT_PUBLIC_SOLANA_TOKEN_RECIPIENT_ADDRESS;
|
||||||
// Validate amount before sending
|
|
||||||
const parsedAmount = parseFloat(amount);
|
|
||||||
if (isNaN(parsedAmount) || parsedAmount <= 0) {
|
|
||||||
setError('Please enter a valid positive amount');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
const handlePayment = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError('');
|
setError('');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await sendAtomPayment(recipientAddress, amount);
|
const tokenAmount = new BN(amount);
|
||||||
|
const result = await sendSolanaTokenPayment(connection, walletState.publicKey!, tokenAmount, walletState.walletType!);
|
||||||
|
|
||||||
if (result.status === 'success' && result.hash) {
|
if (result.success && result.transactionSignature) {
|
||||||
onPaymentComplete(result.hash);
|
onPaymentComplete(result.transactionSignature);
|
||||||
} else {
|
} else {
|
||||||
setError(result.message || 'Payment failed. Please try again.');
|
setError(result.error || `${process.env.NEXT_PUBLIC_SOLANA_TOKEN_SYMBOL} payment failed. Please try again.`);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setError(error instanceof Error ? error.message : 'Payment failed. Please try again.');
|
setError(error instanceof Error ? error.message : 'Payment failed. Please try again.');
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
}, [connection, walletState, amount, onPaymentComplete]);
|
||||||
|
|
||||||
if (!isOpen) return null;
|
if (!isOpen) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 flex items-center justify-center p-4 z-50" style={{ background: 'rgba(15, 23, 42, 0.75)' }}>
|
<div className="fixed inset-0 flex items-center justify-center p-4 z-50" style={{ background: 'rgba(15, 23, 42, 0.75)' }}>
|
||||||
<div className="max-w-md w-full rounded-xl shadow-xl animate-appear"
|
<div className="max-w-md w-full rounded-xl shadow-xl animate-appear"
|
||||||
style={{ background: 'var(--card-bg)', border: '1px solid var(--card-border)' }}>
|
style={{ background: 'var(--card-bg)', border: '1px solid var(--card-border)' }}>
|
||||||
<div className="p-6 border-b" style={{ borderColor: 'var(--card-border)' }}>
|
<div className="p-6 border-b" style={{ borderColor: 'var(--card-border)' }}>
|
||||||
<h2 className="text-xl font-semibold" style={{ color: 'var(--foreground)' }}>Complete Payment</h2>
|
<h2 className="text-xl font-semibold" style={{ color: 'var(--foreground)' }}>
|
||||||
|
Complete {process.env.NEXT_PUBLIC_SOLANA_TOKEN_SYMBOL} Payment
|
||||||
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="p-6 space-y-6">
|
<div className="p-6 space-y-6">
|
||||||
@ -87,27 +89,30 @@ export default function PaymentModal({
|
|||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="amount" className="block text-sm font-medium mb-2" style={{ color: 'var(--foreground)' }}>
|
<label htmlFor="amount" className="block text-sm font-medium mb-2" style={{ color: 'var(--foreground)' }}>
|
||||||
Amount (ATOM)
|
Amount ({process.env.NEXT_PUBLIC_SOLANA_TOKEN_SYMBOL})
|
||||||
</label>
|
</label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<input
|
<input
|
||||||
id="amount"
|
id="amount"
|
||||||
type="number"
|
type="number"
|
||||||
min="0.01"
|
value={amount / divisor}
|
||||||
step="0.01"
|
disabled={true} // Fixed amount for Solana tokens
|
||||||
value={amount}
|
|
||||||
onChange={handleAmountChange}
|
|
||||||
className="w-full p-3 pr-12 rounded-md"
|
className="w-full p-3 pr-12 rounded-md"
|
||||||
style={{
|
style={{
|
||||||
background: 'var(--card-bg)',
|
background: 'var(--card-bg)',
|
||||||
border: '1px solid var(--input-border)',
|
border: '1px solid var(--input-border)',
|
||||||
color: 'var(--foreground)'
|
color: 'var(--foreground)',
|
||||||
|
opacity: '0.7'
|
||||||
}}
|
}}
|
||||||
|
readOnly
|
||||||
/>
|
/>
|
||||||
<div className="absolute inset-y-0 right-0 flex items-center pr-3 pointer-events-none">
|
<div className="absolute inset-y-0 right-0 flex items-center pr-3 pointer-events-none">
|
||||||
<span className="text-sm font-medium" style={{ color: 'var(--muted)' }}>ATOM</span>
|
<span className="text-sm font-medium" style={{ color: 'var(--muted)' }}>{process.env.NEXT_PUBLIC_SOLANA_TOKEN_SYMBOL}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<p className="text-xs mt-1" style={{ color: 'var(--muted)' }}>
|
||||||
|
Fixed amount required for deployment
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
@ -146,7 +151,7 @@ export default function PaymentModal({
|
|||||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||||
</svg>
|
</svg>
|
||||||
)}
|
)}
|
||||||
{loading ? 'Processing...' : 'Pay with Keplr'}
|
{loading ? 'Processing...' : 'Pay with Solana Wallet'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
148
src/components/SolanaConnect.tsx
Normal file
148
src/components/SolanaConnect.tsx
Normal file
@ -0,0 +1,148 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { connectSolanaWallet, disconnectSolanaWallet, checkSolanaWalletConnection } from '@/services/solana';
|
||||||
|
import { SolanaWalletType, SolanaWalletState } from '@/types';
|
||||||
|
|
||||||
|
interface SolanaConnectProps {
|
||||||
|
onConnect: (walletState: SolanaWalletState) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SolanaConnect({ onConnect }: SolanaConnectProps) {
|
||||||
|
const [connecting, setConnecting] = useState(false);
|
||||||
|
const [walletState, setWalletState] = useState<SolanaWalletState>({
|
||||||
|
connected: false,
|
||||||
|
publicKey: null,
|
||||||
|
walletType: null
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleConnect = async (walletType: SolanaWalletType) => {
|
||||||
|
setConnecting(true);
|
||||||
|
try {
|
||||||
|
const newWalletState = await connectSolanaWallet(walletType);
|
||||||
|
setWalletState(newWalletState);
|
||||||
|
onConnect(newWalletState);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to connect to Solana wallet:', error);
|
||||||
|
alert(error instanceof Error ? error.message : 'Failed to connect wallet');
|
||||||
|
} finally {
|
||||||
|
setConnecting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDisconnect = async () => {
|
||||||
|
if (walletState.walletType) {
|
||||||
|
try {
|
||||||
|
await disconnectSolanaWallet(walletState.walletType);
|
||||||
|
const disconnectedState = {
|
||||||
|
connected: false,
|
||||||
|
publicKey: null,
|
||||||
|
walletType: null
|
||||||
|
};
|
||||||
|
setWalletState(disconnectedState);
|
||||||
|
onConnect(disconnectedState);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to disconnect wallet:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Check for auto-connection on page load
|
||||||
|
const checkConnection = () => {
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
// Check Phantom
|
||||||
|
if (window.phantom?.solana && checkSolanaWalletConnection('phantom')) {
|
||||||
|
handleConnect('phantom');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check Solflare
|
||||||
|
if (window.solflare && checkSolanaWalletConnection('solflare')) {
|
||||||
|
handleConnect('solflare');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
checkConnection();
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center p-4 rounded-lg">
|
||||||
|
{walletState.connected ? (
|
||||||
|
<div className="flex flex-col items-center w-full">
|
||||||
|
<div className="flex items-center mb-2">
|
||||||
|
<span className="w-3 h-3 rounded-full mr-2" style={{ backgroundColor: 'var(--success)' }}></span>
|
||||||
|
<p className="font-medium" style={{ color: 'var(--success)' }}>
|
||||||
|
Connected ({walletState.walletType})
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="w-full p-3 rounded-md mb-3" style={{ background: 'var(--card-bg)', border: '1px solid var(--card-border)' }}>
|
||||||
|
<p className="text-sm font-mono break-all text-center">{walletState.publicKey}</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={handleDisconnect}
|
||||||
|
className="px-4 py-2 rounded-md text-sm transition-colors"
|
||||||
|
style={{
|
||||||
|
backgroundColor: 'var(--muted)',
|
||||||
|
color: 'var(--foreground)',
|
||||||
|
border: '1px solid var(--input-border)'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Disconnect
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="w-full space-y-3">
|
||||||
|
<button
|
||||||
|
onClick={() => handleConnect('phantom')}
|
||||||
|
disabled={connecting || !window.phantom?.solana}
|
||||||
|
className="px-6 py-3 rounded-md w-full transition-colors disabled:opacity-50"
|
||||||
|
style={{
|
||||||
|
backgroundColor: connecting ? 'var(--muted)' : 'var(--primary)',
|
||||||
|
color: 'var(--primary-foreground)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-center">
|
||||||
|
{connecting && (
|
||||||
|
<svg className="animate-spin -ml-1 mr-3 h-5 w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||||
|
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
{connecting ? 'Connecting...' : 'Connect Phantom Wallet'}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => handleConnect('solflare')}
|
||||||
|
disabled={connecting || !window.solflare}
|
||||||
|
className="px-6 py-3 rounded-md w-full transition-colors disabled:opacity-50"
|
||||||
|
style={{
|
||||||
|
backgroundColor: connecting ? 'var(--muted)' : 'var(--primary)',
|
||||||
|
color: 'var(--primary-foreground)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-center">
|
||||||
|
{connecting && (
|
||||||
|
<svg className="animate-spin -ml-1 mr-3 h-5 w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||||
|
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
{connecting ? 'Connecting...' : 'Connect Solflare Wallet'}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{!window.phantom?.solana && !window.solflare && (
|
||||||
|
<p className="text-sm text-center" style={{ color: 'var(--muted)' }}>
|
||||||
|
Please install Phantom or Solflare wallet extension
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
@ -28,7 +28,7 @@ export default function StatusDisplay({
|
|||||||
error,
|
error,
|
||||||
}: StatusDisplayProps) {
|
}: StatusDisplayProps) {
|
||||||
// Get domain suffix from environment variable
|
// Get domain suffix from environment variable
|
||||||
const domainSuffix = process.env.NEXT_PUBLIC_DOMAIN_SUFFIX || '';
|
const domainSuffix = process.env.NEXT_PUBLIC_DOMAIN_SUFFIX;
|
||||||
if (status === 'idle') return null;
|
if (status === 'idle') return null;
|
||||||
|
|
||||||
const StatusBadge = ({ type }: { type: 'creating' | 'success' | 'error' }) => {
|
const StatusBadge = ({ type }: { type: 'creating' | 'success' | 'error' }) => {
|
||||||
@ -125,11 +125,15 @@ export default function StatusDisplay({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{txHash && <InfoItem label="ATOM Payment Transaction Hash" value={txHash} />}
|
{txHash && <InfoItem label={`${process.env.NEXT_PUBLIC_SOLANA_TOKEN_SYMBOL} Payment Transaction Hash`} value={txHash} />}
|
||||||
{appRecordId && <InfoItem label="Laconic Application Record ID" value={appRecordId} />}
|
{appRecordId && <InfoItem label="Laconic Application Record ID" value={appRecordId} />}
|
||||||
{recordId && <InfoItem label="Laconic Deployment Request Record ID" value={recordId} />}
|
{recordId && <InfoItem label="Laconic Deployment Request Record ID" value={recordId} />}
|
||||||
{lrn && <InfoItem label="Laconic Resource Name (LRN)" value={lrn} />}
|
{lrn && <InfoItem label="Laconic Resource Name (LRN)" value={lrn} />}
|
||||||
{dns && <InfoItem label="Deployment URL (ready in 2-3 minutes)" value={domainSuffix ? `https://${dns}${domainSuffix}` : `http://${dns}`} />}
|
{dns && (
|
||||||
|
domainSuffix
|
||||||
|
? <InfoItem label="Deployment URL (ready in 2-3 minutes)" value={`https://${dns}.${domainSuffix}`} />
|
||||||
|
: <InfoItem label="Please set domain suffix" value="" />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
@ -10,7 +10,7 @@ interface URLFormProps {
|
|||||||
export default function URLForm({ onSubmit, disabled }: URLFormProps) {
|
export default function URLForm({ onSubmit, disabled }: URLFormProps) {
|
||||||
// Get example URL from environment variables or use a default
|
// Get example URL from environment variables or use a default
|
||||||
const exampleUrl = process.env.NEXT_PUBLIC_EXAMPLE_URL || 'https://example.com';
|
const exampleUrl = process.env.NEXT_PUBLIC_EXAMPLE_URL || 'https://example.com';
|
||||||
const [url, setUrl] = useState(exampleUrl);
|
const [url, setUrl] = useState('');
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
const handleSubmit = (e: React.FormEvent) => {
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
@ -49,7 +49,8 @@ export default function URLForm({ onSubmit, disabled }: URLFormProps) {
|
|||||||
// All validations passed
|
// All validations passed
|
||||||
setError('');
|
setError('');
|
||||||
onSubmit(trimmedUrl);
|
onSubmit(trimmedUrl);
|
||||||
} catch (_) {
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
setError('Please enter a valid URL (e.g., https://example.com)');
|
setError('Please enter a valid URL (e.g., https://example.com)');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
@ -1,5 +1,24 @@
|
|||||||
|
import { Registry, DENOM as ALNT_DENOM } from '@cerc-io/registry-sdk';
|
||||||
|
import { GasPrice } from '@cosmjs/stargate';
|
||||||
|
|
||||||
import { RegistryConfig } from '../types';
|
import { RegistryConfig } from '../types';
|
||||||
|
|
||||||
|
let registryInstance: Registry | null = null;
|
||||||
|
|
||||||
|
export const getRegistry = (): Registry => {
|
||||||
|
if (!registryInstance) {
|
||||||
|
const config = getRegistryConfig();
|
||||||
|
const gasPrice = GasPrice.fromString(config.fee.gasPrice + ALNT_DENOM);
|
||||||
|
|
||||||
|
registryInstance = new Registry(
|
||||||
|
config.gqlEndpoint,
|
||||||
|
config.rpcEndpoint,
|
||||||
|
{ chainId: config.chainId, gasPrice }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return registryInstance;
|
||||||
|
};
|
||||||
|
|
||||||
export const getRegistryConfig = (): RegistryConfig => {
|
export const getRegistryConfig = (): RegistryConfig => {
|
||||||
// Validate required environment variables
|
// Validate required environment variables
|
||||||
const requiredEnvVars = [
|
const requiredEnvVars = [
|
||||||
@ -25,22 +44,7 @@ export const getRegistryConfig = (): RegistryConfig => {
|
|||||||
authority: process.env.REGISTRY_AUTHORITY!,
|
authority: process.env.REGISTRY_AUTHORITY!,
|
||||||
privateKey: process.env.REGISTRY_USER_KEY!,
|
privateKey: process.env.REGISTRY_USER_KEY!,
|
||||||
fee: {
|
fee: {
|
||||||
gas: process.env.REGISTRY_GAS || '900000',
|
gasPrice: process.env.REGISTRY_GAS_PRICE || '0.001',
|
||||||
fees: process.env.REGISTRY_FEES || '900000alnt',
|
|
||||||
gasPrice: process.env.REGISTRY_GAS_PRICE || '0.025',
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getDeployerLrn = (): string => {
|
|
||||||
if (!process.env.DEPLOYER_LRN) {
|
|
||||||
throw new Error('Missing environment variable: DEPLOYER_LRN');
|
|
||||||
}
|
|
||||||
return process.env.DEPLOYER_LRN;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getAppName = (): string => {
|
|
||||||
return process.env.APP_NAME || 'atom-deploy';
|
|
||||||
};
|
|
||||||
|
|
||||||
export const COSMOS_DENOM = 'uatom';
|
|
@ -1,260 +0,0 @@
|
|||||||
import { SigningStargateClient } from '@cosmjs/stargate';
|
|
||||||
import { TransactionResponse } from '../types';
|
|
||||||
import { COSMOS_DENOM } from '../config';
|
|
||||||
|
|
||||||
export const connectKeplr = async (): Promise<string | null> => {
|
|
||||||
if (!window.keplr) {
|
|
||||||
alert('Keplr wallet extension is not installed!');
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const chainId = process.env.NEXT_PUBLIC_COSMOS_CHAIN_ID || 'cosmoshub-4';
|
|
||||||
|
|
||||||
// Try to suggest chain if custom network
|
|
||||||
if (chainId !== 'cosmoshub-4') {
|
|
||||||
try {
|
|
||||||
// Check if we need to suggest the chain to Keplr
|
|
||||||
await window.keplr.getKey(chainId).catch(async () => {
|
|
||||||
// Chain needs to be suggested
|
|
||||||
if (process.env.NEXT_PUBLIC_COSMOS_RPC_URL) {
|
|
||||||
await window.keplr.experimentalSuggestChain({
|
|
||||||
chainId: chainId,
|
|
||||||
chainName: chainId,
|
|
||||||
rpc: process.env.NEXT_PUBLIC_COSMOS_RPC_URL,
|
|
||||||
rest: process.env.NEXT_PUBLIC_COSMOS_REST_URL || process.env.NEXT_PUBLIC_COSMOS_RPC_URL,
|
|
||||||
bip44: {
|
|
||||||
coinType: 118,
|
|
||||||
},
|
|
||||||
bech32Config: {
|
|
||||||
bech32PrefixAccAddr: "cosmos",
|
|
||||||
bech32PrefixAccPub: "cosmospub",
|
|
||||||
bech32PrefixValAddr: "cosmosvaloper",
|
|
||||||
bech32PrefixValPub: "cosmosvaloperpub",
|
|
||||||
bech32PrefixConsAddr: "cosmosvalcons",
|
|
||||||
bech32PrefixConsPub: "cosmosvalconspub",
|
|
||||||
},
|
|
||||||
currencies: [
|
|
||||||
{
|
|
||||||
coinDenom: "ATOM",
|
|
||||||
coinMinimalDenom: "uatom",
|
|
||||||
coinDecimals: 6,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
feeCurrencies: [
|
|
||||||
{
|
|
||||||
coinDenom: "ATOM",
|
|
||||||
coinMinimalDenom: "uatom",
|
|
||||||
coinDecimals: 6,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
stakeCurrency: {
|
|
||||||
coinDenom: "ATOM",
|
|
||||||
coinMinimalDenom: "uatom",
|
|
||||||
coinDecimals: 6,
|
|
||||||
},
|
|
||||||
gasPriceStep: {
|
|
||||||
low: 0.01,
|
|
||||||
average: 0.025,
|
|
||||||
high: 0.04,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} catch (suggestError) {
|
|
||||||
console.warn("Failed to suggest chain to Keplr:", suggestError);
|
|
||||||
// Continue anyway, as enable might still work
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Enable Keplr for the specified chain
|
|
||||||
await window.keplr.enable(chainId);
|
|
||||||
const offlineSigner = window.keplr.getOfflineSigner(chainId);
|
|
||||||
|
|
||||||
// Get the user's account
|
|
||||||
const accounts = await offlineSigner.getAccounts();
|
|
||||||
if (!accounts || accounts.length === 0) {
|
|
||||||
console.error('No accounts found in Keplr wallet');
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return accounts[0].address;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to connect to Keplr wallet:', error);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const sendAtomPayment = async (
|
|
||||||
recipientAddress: string,
|
|
||||||
amount: string
|
|
||||||
): Promise<TransactionResponse> => {
|
|
||||||
try {
|
|
||||||
if (!window.keplr) {
|
|
||||||
return {
|
|
||||||
hash: '',
|
|
||||||
status: 'error',
|
|
||||||
message: 'Keplr wallet extension is not installed!'
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate recipient address is a valid cosmos address
|
|
||||||
if (!recipientAddress || !recipientAddress.startsWith('cosmos1')) {
|
|
||||||
return {
|
|
||||||
hash: '',
|
|
||||||
status: 'error',
|
|
||||||
message: 'Invalid recipient address. Must be a valid Cosmos address starting with cosmos1'
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate amount is a positive number
|
|
||||||
const parsedAmount = parseFloat(amount);
|
|
||||||
if (isNaN(parsedAmount) || parsedAmount <= 0) {
|
|
||||||
return {
|
|
||||||
hash: '',
|
|
||||||
status: 'error',
|
|
||||||
message: 'Invalid amount. Must be a positive number'
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get the chain ID from environment variables or use default
|
|
||||||
const chainId = process.env.NEXT_PUBLIC_COSMOS_CHAIN_ID || 'cosmoshub-4';
|
|
||||||
|
|
||||||
// Enable the chain in Keplr, following same logic as connectKeplr
|
|
||||||
if (chainId !== 'cosmoshub-4') {
|
|
||||||
try {
|
|
||||||
// Check if we need to suggest the chain to Keplr
|
|
||||||
await window.keplr.getKey(chainId).catch(async () => {
|
|
||||||
// Chain needs to be suggested
|
|
||||||
if (process.env.NEXT_PUBLIC_COSMOS_RPC_URL) {
|
|
||||||
await window.keplr.experimentalSuggestChain({
|
|
||||||
chainId: chainId,
|
|
||||||
chainName: chainId,
|
|
||||||
rpc: process.env.NEXT_PUBLIC_COSMOS_RPC_URL,
|
|
||||||
rest: process.env.NEXT_PUBLIC_COSMOS_REST_URL || process.env.NEXT_PUBLIC_COSMOS_RPC_URL,
|
|
||||||
bip44: { coinType: 118 },
|
|
||||||
bech32Config: {
|
|
||||||
bech32PrefixAccAddr: "cosmos",
|
|
||||||
bech32PrefixAccPub: "cosmospub",
|
|
||||||
bech32PrefixValAddr: "cosmosvaloper",
|
|
||||||
bech32PrefixValPub: "cosmosvaloperpub",
|
|
||||||
bech32PrefixConsAddr: "cosmosvalcons",
|
|
||||||
bech32PrefixConsPub: "cosmosvalconspub",
|
|
||||||
},
|
|
||||||
currencies: [{ coinDenom: "ATOM", coinMinimalDenom: "uatom", coinDecimals: 6 }],
|
|
||||||
feeCurrencies: [{ coinDenom: "ATOM", coinMinimalDenom: "uatom", coinDecimals: 6 }],
|
|
||||||
stakeCurrency: { coinDenom: "ATOM", coinMinimalDenom: "uatom", coinDecimals: 6 },
|
|
||||||
gasPriceStep: { low: 0.01, average: 0.025, high: 0.04 },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} catch (suggestError) {
|
|
||||||
console.warn("Failed to suggest chain to Keplr:", suggestError);
|
|
||||||
// Continue anyway, as enable might still work
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Enable the chain in Keplr
|
|
||||||
await window.keplr.enable(chainId);
|
|
||||||
const offlineSigner = window.keplr.getOfflineSigner(chainId);
|
|
||||||
|
|
||||||
// Create the Stargate client
|
|
||||||
const rpcEndpoint = process.env.NEXT_PUBLIC_COSMOS_RPC_URL;
|
|
||||||
if (!rpcEndpoint) {
|
|
||||||
return {
|
|
||||||
hash: '',
|
|
||||||
status: 'error',
|
|
||||||
message: 'NEXT_PUBLIC_COSMOS_RPC_URL environment variable is not set'
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const client = await SigningStargateClient.connectWithSigner(
|
|
||||||
rpcEndpoint,
|
|
||||||
offlineSigner
|
|
||||||
);
|
|
||||||
|
|
||||||
// Get the user's account
|
|
||||||
const accounts = await offlineSigner.getAccounts();
|
|
||||||
if (!accounts || accounts.length === 0) {
|
|
||||||
return {
|
|
||||||
hash: '',
|
|
||||||
status: 'error',
|
|
||||||
message: 'No accounts found in Keplr wallet'
|
|
||||||
};
|
|
||||||
}
|
|
||||||
const sender = accounts[0].address;
|
|
||||||
|
|
||||||
// Convert amount to microdenom (e.g., ATOM to uatom)
|
|
||||||
const microAmount = convertToMicroDenom(amount);
|
|
||||||
|
|
||||||
// Send the transaction
|
|
||||||
const result = await client.sendTokens(
|
|
||||||
sender,
|
|
||||||
recipientAddress,
|
|
||||||
[{ denom: COSMOS_DENOM, amount: microAmount }],
|
|
||||||
{
|
|
||||||
amount: [{ denom: COSMOS_DENOM, amount: '5000' }],
|
|
||||||
gas: '200000',
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!result || !result.transactionHash) {
|
|
||||||
return {
|
|
||||||
hash: '',
|
|
||||||
status: 'error',
|
|
||||||
message: 'Transaction did not return a valid hash'
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
hash: result.transactionHash,
|
|
||||||
status: 'success',
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to send ATOM payment:', error);
|
|
||||||
// Provide more descriptive error messages for common errors
|
|
||||||
if (error instanceof Error) {
|
|
||||||
const errorMessage = error.message.toLowerCase();
|
|
||||||
|
|
||||||
if (errorMessage.includes('insufficient funds')) {
|
|
||||||
return {
|
|
||||||
hash: '',
|
|
||||||
status: 'error',
|
|
||||||
message: 'Insufficient funds in your Keplr wallet to complete this transaction'
|
|
||||||
};
|
|
||||||
} else if (errorMessage.includes('rejected')) {
|
|
||||||
return {
|
|
||||||
hash: '',
|
|
||||||
status: 'error',
|
|
||||||
message: 'Transaction was rejected in the Keplr wallet'
|
|
||||||
};
|
|
||||||
} else if (errorMessage.includes('timeout')) {
|
|
||||||
return {
|
|
||||||
hash: '',
|
|
||||||
status: 'error',
|
|
||||||
message: 'Transaction timed out. Please try again'
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
hash: '',
|
|
||||||
status: 'error',
|
|
||||||
message: error.message
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
hash: '',
|
|
||||||
status: 'error',
|
|
||||||
message: 'Unknown error occurred while sending payment'
|
|
||||||
};
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Helper function to convert from ATOM to uatom (1 ATOM = 1,000,000 uatom)
|
|
||||||
export const convertToMicroDenom = (amount: string): string => {
|
|
||||||
const parsedAmount = parseFloat(amount);
|
|
||||||
if (isNaN(parsedAmount)) {
|
|
||||||
throw new Error('Invalid amount');
|
|
||||||
}
|
|
||||||
return Math.floor(parsedAmount * 1_000_000).toString();
|
|
||||||
};
|
|
116
src/services/laconicTransfer.ts
Normal file
116
src/services/laconicTransfer.ts
Normal file
@ -0,0 +1,116 @@
|
|||||||
|
import { Account, DENOM as ALNT_DENOM } from '@cerc-io/registry-sdk';
|
||||||
|
import { DeliverTxResponse } from '@cosmjs/stargate';
|
||||||
|
|
||||||
|
import { registryTransactionWithRetry } from '@/app/api/registry/route';
|
||||||
|
import { getRegistry, getRegistryConfig } from '../config';
|
||||||
|
import { LaconicTransferResult } from '../types';
|
||||||
|
|
||||||
|
export const transferLNTTokens = async (): Promise<LaconicTransferResult> => {
|
||||||
|
try {
|
||||||
|
const registryConfig = getRegistryConfig();
|
||||||
|
const registry = getRegistry();
|
||||||
|
|
||||||
|
console.log('Resolving deployer LRN to get payment address...');
|
||||||
|
|
||||||
|
// Resolve the deployer LRN to get the payment address
|
||||||
|
const deployerLrn = process.env.DEPLOYER_LRN;
|
||||||
|
if (!deployerLrn) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: 'DEPLOYER_LRN environment variable is required'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolveResult = await registry.resolveNames([deployerLrn]);
|
||||||
|
console.log('Resolve result:', resolveResult);
|
||||||
|
|
||||||
|
if (!resolveResult || resolveResult.length === 0) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: `Failed to resolve deployer LRN: ${deployerLrn}`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const deployerRecord = resolveResult[0];
|
||||||
|
if (!deployerRecord.attributes) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: 'Deployer record has no attributes'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the paymentAddress attribute
|
||||||
|
const paymentAddress = deployerRecord.attributes.paymentAddress
|
||||||
|
const deployerMinPayment = (deployerRecord.attributes.minimumPayment as string).split(ALNT_DENOM)[0]
|
||||||
|
|
||||||
|
console.log('Found payment address:', paymentAddress);
|
||||||
|
console.log('Found minimum payment:', deployerMinPayment);
|
||||||
|
|
||||||
|
console.log('Initiating LNT transfer...');
|
||||||
|
|
||||||
|
// Send tokens from prefilled account to payment address
|
||||||
|
const transferResult = await sendTokensToAccount(
|
||||||
|
registryConfig.privateKey,
|
||||||
|
paymentAddress,
|
||||||
|
deployerMinPayment
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log('LNT transfer result:', transferResult);
|
||||||
|
|
||||||
|
if (!transferResult.transactionHash) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: 'LNT transfer failed - no transaction hash returned'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
transactionHash: transferResult.transactionHash
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to transfer LNT tokens:', error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error instanceof Error ? error.message : 'Unknown error during LNT transfer'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getAccount = async (accountPrivateKey: string): Promise<Account> => {
|
||||||
|
const account = new Account(
|
||||||
|
Buffer.from(accountPrivateKey, 'hex'),
|
||||||
|
);
|
||||||
|
await account.init();
|
||||||
|
|
||||||
|
return account;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const sendTokensToAccount = async (
|
||||||
|
senderPrivateKey: string,
|
||||||
|
receiverAddress: string,
|
||||||
|
amount: string,
|
||||||
|
): Promise<DeliverTxResponse> => {
|
||||||
|
const registry = getRegistry();
|
||||||
|
|
||||||
|
const account = await getAccount(senderPrivateKey);
|
||||||
|
const laconicClient = await registry.getLaconicClient(account);
|
||||||
|
|
||||||
|
const txResponse: DeliverTxResponse = await registryTransactionWithRetry(
|
||||||
|
() =>
|
||||||
|
laconicClient.sendTokens(
|
||||||
|
account.address,
|
||||||
|
receiverAddress,
|
||||||
|
[
|
||||||
|
{
|
||||||
|
denom: ALNT_DENOM,
|
||||||
|
amount,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"auto",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
return txResponse;
|
||||||
|
}
|
@ -1,12 +1,11 @@
|
|||||||
import axios from 'axios';
|
|
||||||
import { CreateRecordResponse } from '../types';
|
import { CreateRecordResponse } from '../types';
|
||||||
|
|
||||||
export const createApplicationDeploymentRequest = async (
|
export const createApplicationDeploymentRequest = async (
|
||||||
url: string,
|
url: string,
|
||||||
txHash: string
|
txHash: string,
|
||||||
): Promise<CreateRecordResponse> => {
|
): Promise<CreateRecordResponse> => {
|
||||||
try {
|
try {
|
||||||
console.log(`Creating deployment request for URL: ${url} with transaction: ${txHash}`);
|
console.log(`Creating deployment request for URL: ${url} with transaction: ${txHash} using ${process.env.NEXT_PUBLIC_SOLANA_TOKEN_SYMBOL} payment`);
|
||||||
|
|
||||||
// Call our serverless API endpoint to handle the registry interaction
|
// Call our serverless API endpoint to handle the registry interaction
|
||||||
const response = await fetch('/api/registry', {
|
const response = await fetch('/api/registry', {
|
||||||
@ -50,63 +49,3 @@ export const createApplicationDeploymentRequest = async (
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const verifyTransaction = async (txHash: string): Promise<boolean> => {
|
|
||||||
try {
|
|
||||||
// Use the public Cosmos API URL for verification queries
|
|
||||||
const apiEndpoint = process.env.NEXT_PUBLIC_COSMOS_API_URL;
|
|
||||||
if (!apiEndpoint) {
|
|
||||||
console.error('NEXT_PUBLIC_COSMOS_API_URL environment variable not set');
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Use Axios to directly query the Cosmos transaction via REST API
|
|
||||||
const response = await axios.get(`${apiEndpoint}/cosmos/tx/v1beta1/txs/${txHash}`);
|
|
||||||
|
|
||||||
// Check if transaction exists and was successful
|
|
||||||
// The Cosmos API returns a tx_response object with a code field - 0 means success
|
|
||||||
if (response.data &&
|
|
||||||
response.data.tx_response &&
|
|
||||||
response.data.tx_response.code === 0) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Also check for successful transactions with code === undefined (some nodes report it this way)
|
|
||||||
if (response.data &&
|
|
||||||
response.data.tx_response &&
|
|
||||||
response.data.tx_response.code === undefined &&
|
|
||||||
response.data.tx_response.height) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Also fallback to checking if the transaction has a height (was included in a block)
|
|
||||||
if (response.data &&
|
|
||||||
response.data.tx_response &&
|
|
||||||
response.data.tx_response.height &&
|
|
||||||
!response.data.tx_response.code) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to verify transaction:', error);
|
|
||||||
|
|
||||||
// If the API call fails, try checking a public explorer API as fallback
|
|
||||||
try {
|
|
||||||
// Try a different URL format that some RPC nodes might use
|
|
||||||
const rpcEndpoint = process.env.NEXT_PUBLIC_COSMOS_RPC_URL;
|
|
||||||
const fallbackResponse = await axios.get(`${rpcEndpoint}/tx?hash=0x${txHash}`);
|
|
||||||
|
|
||||||
if (fallbackResponse.data &&
|
|
||||||
fallbackResponse.data.result &&
|
|
||||||
(fallbackResponse.data.result.height ||
|
|
||||||
(fallbackResponse.data.result.tx_result &&
|
|
||||||
fallbackResponse.data.result.tx_result.code === 0))) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
} catch (fallbackError) {
|
|
||||||
console.error('Fallback verification also failed:', fallbackError);
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
};
|
|
226
src/services/solana.ts
Normal file
226
src/services/solana.ts
Normal file
@ -0,0 +1,226 @@
|
|||||||
|
import assert from 'assert';
|
||||||
|
import BN from 'bn.js';
|
||||||
|
import { Connection, PublicKey, Transaction } from '@solana/web3.js';
|
||||||
|
import {
|
||||||
|
TOKEN_PROGRAM_ID,
|
||||||
|
createTransferInstruction,
|
||||||
|
createAssociatedTokenAccountInstruction,
|
||||||
|
ASSOCIATED_TOKEN_PROGRAM_ID
|
||||||
|
} from '@solana/spl-token';
|
||||||
|
|
||||||
|
import { SolanaPaymentResult, SolanaWalletType, SolanaWalletState } from '../types';
|
||||||
|
|
||||||
|
assert(process.env.NEXT_PUBLIC_SOLANA_TOKEN_MINT_ADDRESS, 'SOLANA_TOKEN_MINT_ADDRESS is required');
|
||||||
|
assert(process.env.NEXT_PUBLIC_SOLANA_TOKEN_RECIPIENT_ADDRESS, 'SOLANA_TOKEN_RECIPIENT_ADDRESS is required');
|
||||||
|
|
||||||
|
const TOKEN_MINT = process.env.NEXT_PUBLIC_SOLANA_TOKEN_MINT_ADDRESS;
|
||||||
|
const PAYMENT_RECEIVER_ADDRESS = process.env.NEXT_PUBLIC_SOLANA_TOKEN_RECIPIENT_ADDRESS;
|
||||||
|
|
||||||
|
export const connectSolanaWallet = async (walletType: SolanaWalletType): Promise<SolanaWalletState> => {
|
||||||
|
try {
|
||||||
|
if (walletType === 'phantom') {
|
||||||
|
if (!window.phantom?.solana) {
|
||||||
|
throw new Error('Phantom wallet not found. Please install Phantom browser extension.');
|
||||||
|
}
|
||||||
|
const response = await window.phantom.solana.connect();
|
||||||
|
return {
|
||||||
|
connected: true,
|
||||||
|
publicKey: response.publicKey.toString(),
|
||||||
|
walletType
|
||||||
|
};
|
||||||
|
} else if (walletType === 'solflare') {
|
||||||
|
if (!window.solflare) {
|
||||||
|
throw new Error('Solflare wallet not found. Please install Solflare browser extension.');
|
||||||
|
}
|
||||||
|
await window.solflare.connect();
|
||||||
|
const publicKey = window.solflare.publicKey?.toString();
|
||||||
|
if (!publicKey) {
|
||||||
|
throw new Error('Failed to get public key from Solflare wallet');
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
connected: true,
|
||||||
|
publicKey,
|
||||||
|
walletType
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`Unsupported wallet type: ${walletType}`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to connect to Solana wallet:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const disconnectSolanaWallet = async (walletType: SolanaWalletType): Promise<void> => {
|
||||||
|
try {
|
||||||
|
let wallet = null;
|
||||||
|
|
||||||
|
if (walletType === 'phantom') {
|
||||||
|
wallet = window.phantom?.solana;
|
||||||
|
} else if (walletType === 'solflare') {
|
||||||
|
wallet = window.solflare;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (wallet && wallet.disconnect) {
|
||||||
|
await wallet.disconnect();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to disconnect Solana wallet:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
async function findAssociatedTokenAddress(
|
||||||
|
walletAddress: PublicKey,
|
||||||
|
tokenMintAddress: PublicKey
|
||||||
|
): Promise<PublicKey> {
|
||||||
|
return PublicKey.findProgramAddressSync(
|
||||||
|
[
|
||||||
|
walletAddress.toBuffer(),
|
||||||
|
TOKEN_PROGRAM_ID.toBuffer(),
|
||||||
|
tokenMintAddress.toBuffer(),
|
||||||
|
],
|
||||||
|
ASSOCIATED_TOKEN_PROGRAM_ID
|
||||||
|
)[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WalletAdapter {
|
||||||
|
signAndSendTransaction(transaction: Transaction): Promise<{ signature: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function sendSolanaTokenPayment(
|
||||||
|
connection: Connection,
|
||||||
|
walletPublicKey: string,
|
||||||
|
tokenAmount: BN,
|
||||||
|
walletType: SolanaWalletType
|
||||||
|
): Promise<SolanaPaymentResult> {
|
||||||
|
try {
|
||||||
|
let wallet: WalletAdapter | null = null;
|
||||||
|
|
||||||
|
if (walletType === 'phantom') {
|
||||||
|
wallet = window.phantom?.solana || null;
|
||||||
|
} else if (walletType === 'solflare') {
|
||||||
|
wallet = window.solflare || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!wallet) {
|
||||||
|
throw new Error(`${walletType} wallet not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const senderPublicKey = new PublicKey(walletPublicKey);
|
||||||
|
const mintPublicKey = new PublicKey(TOKEN_MINT);
|
||||||
|
const receiverPublicKey = new PublicKey(PAYMENT_RECEIVER_ADDRESS);
|
||||||
|
|
||||||
|
console.log('Processing payment with keys:', {
|
||||||
|
sender: senderPublicKey.toBase58(),
|
||||||
|
mint: mintPublicKey.toBase58(),
|
||||||
|
receiver: receiverPublicKey.toBase58(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const senderATA = await findAssociatedTokenAddress(
|
||||||
|
senderPublicKey,
|
||||||
|
mintPublicKey
|
||||||
|
);
|
||||||
|
|
||||||
|
const receiverATA = await findAssociatedTokenAddress(
|
||||||
|
receiverPublicKey,
|
||||||
|
mintPublicKey
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log('Token accounts:', {
|
||||||
|
senderATA: senderATA.toBase58(),
|
||||||
|
receiverATA: receiverATA.toBase58(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const transaction = new Transaction();
|
||||||
|
|
||||||
|
// Check if accounts exist
|
||||||
|
const [senderATAInfo, receiverATAInfo] = await Promise.all([
|
||||||
|
connection.getAccountInfo(senderATA),
|
||||||
|
connection.getAccountInfo(receiverATA),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Create receiver token account if it doesn't exist
|
||||||
|
if (!receiverATAInfo) {
|
||||||
|
console.log('Creating receiver token account');
|
||||||
|
transaction.add(
|
||||||
|
createAssociatedTokenAccountInstruction(
|
||||||
|
senderPublicKey,
|
||||||
|
receiverATA,
|
||||||
|
receiverPublicKey,
|
||||||
|
mintPublicKey
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create sender token account if it doesn't exist
|
||||||
|
if (!senderATAInfo) {
|
||||||
|
console.log('Creating sender token account');
|
||||||
|
transaction.add(
|
||||||
|
createAssociatedTokenAccountInstruction(
|
||||||
|
senderPublicKey,
|
||||||
|
senderATA,
|
||||||
|
senderPublicKey,
|
||||||
|
mintPublicKey
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const amountToSend = BigInt(tokenAmount.toString());
|
||||||
|
|
||||||
|
// Add transfer instruction
|
||||||
|
transaction.add(
|
||||||
|
createTransferInstruction(
|
||||||
|
senderATA,
|
||||||
|
receiverATA,
|
||||||
|
senderPublicKey,
|
||||||
|
amountToSend
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Set transaction details
|
||||||
|
const latestBlockhash = await connection.getLatestBlockhash('confirmed');
|
||||||
|
transaction.recentBlockhash = latestBlockhash.blockhash;
|
||||||
|
transaction.feePayer = senderPublicKey;
|
||||||
|
|
||||||
|
console.log('Sending transaction...');
|
||||||
|
const { signature } = await wallet.signAndSendTransaction(transaction);
|
||||||
|
console.log('Transaction sent:', signature);
|
||||||
|
|
||||||
|
// Confirm transaction
|
||||||
|
const confirmation = await connection.confirmTransaction({
|
||||||
|
signature,
|
||||||
|
blockhash: latestBlockhash.blockhash,
|
||||||
|
lastValidBlockHeight: latestBlockhash.lastValidBlockHeight,
|
||||||
|
}, 'confirmed');
|
||||||
|
|
||||||
|
if (confirmation.value.err) {
|
||||||
|
console.error('Transaction error:', confirmation.value.err);
|
||||||
|
throw new Error(`Transaction failed: ${JSON.stringify(confirmation.value.err)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
transactionSignature: signature
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Payment error:', error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error instanceof Error ? error.message : 'Payment failed'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Helper function to check wallet connection status
|
||||||
|
export const checkSolanaWalletConnection = (walletType: SolanaWalletType): boolean => {
|
||||||
|
try {
|
||||||
|
if (walletType === 'phantom') {
|
||||||
|
return window.phantom?.solana?.isConnected || false;
|
||||||
|
} else if (walletType === 'solflare') {
|
||||||
|
return window.solflare?.isConnected || false;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
@ -1,8 +1,23 @@
|
|||||||
import { Window as KeplrWindow } from "@keplr-wallet/types";
|
import { Transaction } from "@solana/web3.js";
|
||||||
|
|
||||||
// extend the global Window interface to include Keplr
|
// extend the global Window interface to include Solana wallets
|
||||||
declare global {
|
declare global {
|
||||||
interface Window extends KeplrWindow {}
|
interface Window {
|
||||||
|
phantom?: {
|
||||||
|
solana?: {
|
||||||
|
signAndSendTransaction(transaction: Transaction): Promise<{ signature: string }>;
|
||||||
|
connect(): Promise<{ publicKey: { toString(): string } }>;
|
||||||
|
disconnect(): Promise<void>;
|
||||||
|
isConnected: boolean;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
solflare?: {
|
||||||
|
signAndSendTransaction(transaction: Transaction): Promise<{ signature: string }>;
|
||||||
|
connect(): Promise<{ publicKey: { toString(): string } }>;
|
||||||
|
disconnect(): Promise<void>;
|
||||||
|
isConnected: boolean;
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RegistryConfig {
|
export interface RegistryConfig {
|
||||||
@ -13,33 +28,10 @@ export interface RegistryConfig {
|
|||||||
authority: string;
|
authority: string;
|
||||||
privateKey: string;
|
privateKey: string;
|
||||||
fee: {
|
fee: {
|
||||||
gas: string;
|
|
||||||
fees: string;
|
|
||||||
gasPrice: string;
|
gasPrice: string;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TransactionResponse {
|
|
||||||
hash: string;
|
|
||||||
status: 'success' | 'error';
|
|
||||||
message?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface LaconicRecordData {
|
|
||||||
type: string;
|
|
||||||
version: string;
|
|
||||||
name: string;
|
|
||||||
application: string;
|
|
||||||
deployer: string;
|
|
||||||
dns: string;
|
|
||||||
meta: {
|
|
||||||
note: string;
|
|
||||||
repository: string;
|
|
||||||
repository_ref: string;
|
|
||||||
};
|
|
||||||
payment: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CreateRecordResponse {
|
export interface CreateRecordResponse {
|
||||||
id: string;
|
id: string;
|
||||||
applicationRecordId?: string;
|
applicationRecordId?: string;
|
||||||
@ -52,3 +44,31 @@ export interface CreateRecordResponse {
|
|||||||
status: 'success' | 'error';
|
status: 'success' | 'error';
|
||||||
message?: string;
|
message?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type SolanaWalletType = 'phantom' | 'solflare';
|
||||||
|
|
||||||
|
export interface SolanaPaymentResult {
|
||||||
|
success: boolean;
|
||||||
|
transactionSignature?: string;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PaymentModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
url: string;
|
||||||
|
onPaymentComplete: (txHash: string) => void;
|
||||||
|
walletState: SolanaWalletState;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SolanaWalletState {
|
||||||
|
connected: boolean;
|
||||||
|
publicKey: string | null;
|
||||||
|
walletType: SolanaWalletType | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LaconicTransferResult {
|
||||||
|
success: boolean;
|
||||||
|
transactionHash?: string;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
132
src/utils/solanaVerify.ts
Normal file
132
src/utils/solanaVerify.ts
Normal file
@ -0,0 +1,132 @@
|
|||||||
|
import assert from 'assert';
|
||||||
|
import BN from 'bn.js';
|
||||||
|
|
||||||
|
import { Connection, PublicKey } from '@solana/web3.js';
|
||||||
|
import { getAssociatedTokenAddressSync, TOKEN_PROGRAM_ID } from '@solana/spl-token';
|
||||||
|
|
||||||
|
// Simplified transaction info extraction following reference implementation
|
||||||
|
const extractTxInfo = async (connection: Connection, transactionSignature: string): Promise<{ authority: string; amount: string }> => {
|
||||||
|
const result = await connection.getParsedTransaction(transactionSignature, 'confirmed');
|
||||||
|
|
||||||
|
if (!result) {
|
||||||
|
throw new Error('Transaction not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const transferInstruction = result.transaction.message.instructions.find(
|
||||||
|
(instr) => 'parsed' in instr && instr.programId.equals(TOKEN_PROGRAM_ID)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!transferInstruction || !('parsed' in transferInstruction)) {
|
||||||
|
throw new Error('Transfer instruction not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const { info: { amount, authority } } = transferInstruction.parsed;
|
||||||
|
return { authority, amount };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const verifyUnusedSolanaPayment = async (
|
||||||
|
connection: Connection,
|
||||||
|
transactionSignature: string,
|
||||||
|
tokenAmount: BN
|
||||||
|
): Promise<{
|
||||||
|
valid: boolean,
|
||||||
|
reason?: string,
|
||||||
|
amount?: string,
|
||||||
|
sender?: string
|
||||||
|
}> => {
|
||||||
|
try {
|
||||||
|
// TODO: Check if provided signature is already used
|
||||||
|
|
||||||
|
// Fetch transaction details
|
||||||
|
const transactionResult = await connection.getParsedTransaction(transactionSignature, 'confirmed');
|
||||||
|
|
||||||
|
if (!transactionResult) {
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
reason: 'Transaction not found on Solana blockchain'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if transaction was successful
|
||||||
|
if (transactionResult.meta?.err) {
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
reason: `Transaction failed: ${JSON.stringify(transactionResult.meta.err)}`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check transaction timestamp (5-minute window)
|
||||||
|
const txTimestamp = transactionResult.blockTime ? new Date(transactionResult.blockTime * 1000) : null;
|
||||||
|
if (!txTimestamp) {
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
reason: 'Transaction timestamp not available'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const timeDiffMs = now.getTime() - txTimestamp.getTime();
|
||||||
|
const timeWindowMs = 5 * 60 * 1000; // 5 minutes
|
||||||
|
|
||||||
|
if (timeDiffMs > timeWindowMs) {
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
reason: `Transaction is older than 5 minutes (${Math.round(timeDiffMs / 60000)} minutes old)`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract transaction info using simplified approach
|
||||||
|
const { amount, authority } = await extractTxInfo(connection, transactionSignature);
|
||||||
|
|
||||||
|
// Verify amount using BN comparison like in reference
|
||||||
|
const transactionAmount = new BN(amount);
|
||||||
|
if (transactionAmount.lt(tokenAmount)) {
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
reason: `Payment amount (${amount}) is less than required (${tokenAmount.toString()})`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const expectedTokenAccount = getAssociatedTokenAddressSync(
|
||||||
|
new PublicKey(process.env.NEXT_PUBLIC_SOLANA_TOKEN_MINT_ADDRESS!),
|
||||||
|
new PublicKey(process.env.NEXT_PUBLIC_SOLANA_TOKEN_RECIPIENT_ADDRESS!),
|
||||||
|
// Allow recipient account address to be off the curve (i.e now having a private key)
|
||||||
|
true
|
||||||
|
);
|
||||||
|
|
||||||
|
// Verify recipient address by checking the transaction instructions
|
||||||
|
let foundValidTransfer = false;
|
||||||
|
|
||||||
|
for (const instruction of transactionResult.transaction.message.instructions) {
|
||||||
|
if ('parsed' in instruction && instruction.programId.equals(TOKEN_PROGRAM_ID)) {
|
||||||
|
const parsed = instruction.parsed;
|
||||||
|
if (parsed.type === 'transferChecked' || parsed.type === 'transfer') {
|
||||||
|
// Verify amount and recipient's associated token address
|
||||||
|
if (parsed.info.amount === amount && parsed.info.destination === expectedTokenAccount.toBase58() ) {
|
||||||
|
foundValidTransfer = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!foundValidTransfer) {
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
reason: 'Valid Solana token transfer not found in transaction'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
valid: true,
|
||||||
|
amount,
|
||||||
|
sender: authority
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error verifying Solana payment:', error);
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
reason: `Failed to verify transaction: ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
@ -22,6 +22,6 @@
|
|||||||
"@/*": ["./src/*"]
|
"@/*": ["./src/*"]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", "next.config.mjs"],
|
||||||
"exclude": ["node_modules"]
|
"exclude": ["node_modules"]
|
||||||
}
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user