Use solana GOR token payments to deploy apps (#1)
Part of https://www.notion.so/Laconic-Mainnet-Plan-1eca6b22d47280569cd0d1e6d711d949 Co-authored-by: Shreerang Kale <shreerangkale@gmail.com> Co-authored-by: Nabarun <nabarun@deepstacksoft.com> Reviewed-on: #1 Co-authored-by: shreerang <shreerang@noreply.git.vdb.to> Co-committed-by: shreerang <shreerang@noreply.git.vdb.to>
This commit is contained in:
parent
765fe3d49a
commit
acabd4569f
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=400 # 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_GAS_PRICE=0.001
|
||||
REGISTRY_BOND_ID=
|
||||
REGISTRY_AUTHORITY=
|
||||
REGISTRY_USER_KEY=
|
||||
|
||||
# Application Configuration
|
||||
DEPLOYER_LRN=
|
||||
NEXT_PUBLIC_DOMAIN_SUFFIX=
|
8
.gitignore
vendored
8
.gitignore
vendored
@ -31,11 +31,15 @@ yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# env files (can opt-in for committing if needed)
|
||||
.env*
|
||||
.env.local
|
||||
.env
|
||||
|
||||
# registry env files
|
||||
.registry.env
|
||||
.app.env
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
|
231
CLAUDE.md
Normal file
231
CLAUDE.md
Normal file
@ -0,0 +1,231 @@
|
||||
# CLAUDE.md - GOR Deploy Development Guide
|
||||
|
||||
## Project Overview
|
||||
|
||||
**GOR Deploy** is a Next.js application that bridges Solana blockchain token payments with Laconic Registry deployments. Users pay with GOR tokens (or any configurable Solana SPL token) and the app creates deployment records in the Laconic Registry for frontend applications.
|
||||
|
||||
## Quick Commands
|
||||
|
||||
```bash
|
||||
# Development
|
||||
npm run dev # Start development server (http://localhost:3000)
|
||||
npm run build # Build for production
|
||||
npm start # Start production server
|
||||
npm run lint # Run ESLint
|
||||
|
||||
# Testing
|
||||
# No test framework configured - check README for testing approach
|
||||
```
|
||||
|
||||
## Project Architecture
|
||||
|
||||
### Tech Stack
|
||||
- **Frontend**: Next.js 15.3.1 with React 19
|
||||
- **Styling**: TailwindCSS 4
|
||||
- **Blockchain**:
|
||||
- Solana Web3.js for payments
|
||||
- @cerc-io/registry-sdk for Laconic Registry
|
||||
- CosmJS for Cosmos blockchain interactions
|
||||
- **Wallet Integration**: Phantom & Solflare
|
||||
- **Language**: TypeScript
|
||||
|
||||
### Key Dependencies
|
||||
- `@solana/web3.js` - Solana blockchain interactions
|
||||
- `@solana/spl-token` - SPL token handling
|
||||
- `@cerc-io/registry-sdk` - Laconic Registry operations
|
||||
- `@cosmjs/stargate` - Cosmos blockchain operations
|
||||
- `axios` - HTTP requests for GitHub API
|
||||
- `bn.js` - Big number arithmetic
|
||||
- `big.js` - Decimal arithmetic
|
||||
|
||||
## Application Flow
|
||||
|
||||
1. **Wallet Connection**: User connects Phantom or Solflare wallet
|
||||
2. **URL Input**: User enters repository URL to deploy
|
||||
3. **Payment**: Fixed amount payment in GOR/SPL tokens to configured recipient
|
||||
4. **Verification**: Server verifies Solana transaction (5-minute window)
|
||||
5. **LNT Transfer**: Server transfers LNT tokens for registry fees
|
||||
6. **Registry Records**: Creates ApplicationRecord and ApplicationDeploymentRequest
|
||||
7. **Name Mapping**: Sets LRN mappings in Laconic Registry
|
||||
|
||||
## Key Files and Components
|
||||
|
||||
### Frontend Components
|
||||
- `src/app/page.tsx` - Main application page with 3-step flow
|
||||
- `src/components/PaymentModal.tsx` - Solana payment interface
|
||||
- `src/components/SolanaConnect.tsx` - Wallet connection UI
|
||||
- `src/components/URLForm.tsx` - URL input form
|
||||
- `src/components/StatusDisplay.tsx` - Deployment status display
|
||||
|
||||
### Services and Utils
|
||||
- `src/services/solana.ts` - Solana wallet and payment operations
|
||||
- `src/services/registry.ts` - Client-side registry service wrapper
|
||||
- `src/services/laconicTransfer.ts` - LNT token transfer logic
|
||||
- `src/utils/solanaVerify.ts` - Payment verification with replay protection
|
||||
- `src/config/index.ts` - Registry configuration management
|
||||
|
||||
### API Routes
|
||||
- `src/app/api/registry/route.ts` - Main server-side registry operations
|
||||
|
||||
## Environment Configuration
|
||||
|
||||
### Client-side Variables (NEXT_PUBLIC_*)
|
||||
```bash
|
||||
NEXT_PUBLIC_SOLANA_RPC_URL=https://api.mainnet-beta.solana.com
|
||||
NEXT_PUBLIC_SOLANA_TOKEN_MINT_ADDRESS=<SPL_TOKEN_MINT>
|
||||
NEXT_PUBLIC_SOLANA_TOKEN_RECIPIENT_ADDRESS=<RECIPIENT_WALLET>
|
||||
NEXT_PUBLIC_SOLANA_TOKEN_SYMBOL=GOR
|
||||
NEXT_PUBLIC_SOLANA_TOKEN_DECIMALS=6
|
||||
NEXT_PUBLIC_SOLANA_PAYMENT_AMOUNT=400
|
||||
NEXT_PUBLIC_DOMAIN_SUFFIX=.example.com
|
||||
NEXT_PUBLIC_EXAMPLE_URL=https://github.com/cerc-io/laconic-registry-cli
|
||||
```
|
||||
|
||||
### Server-side Variables
|
||||
```bash
|
||||
REGISTRY_CHAIN_ID=laconic-mainnet
|
||||
REGISTRY_GQL_ENDPOINT=https://laconicd-mainnet-1.laconic.com/api
|
||||
REGISTRY_RPC_ENDPOINT=https://laconicd-mainnet-1.laconic.com
|
||||
REGISTRY_BOND_ID=<BOND_ID>
|
||||
REGISTRY_AUTHORITY=<AUTHORITY_NAME>
|
||||
REGISTRY_USER_KEY=<PRIVATE_KEY>
|
||||
APP_NAME=gor-deploy
|
||||
DEPLOYER_LRN=<DEPLOYER_LRN>
|
||||
```
|
||||
|
||||
## Code Patterns and Conventions
|
||||
|
||||
### State Management
|
||||
- Uses React `useState` for component state
|
||||
- No global state management library
|
||||
- Dynamic imports to avoid SSR issues with browser APIs
|
||||
|
||||
### Error Handling
|
||||
- Comprehensive try-catch blocks in all async operations
|
||||
- User-friendly error messages in UI
|
||||
- Server-side error logging with detailed context
|
||||
|
||||
### Security Best Practices
|
||||
- Environment variables for sensitive data
|
||||
- Transaction verification with replay protection
|
||||
- Proper SPL token account creation and validation
|
||||
- Private key never exposed to client-side
|
||||
|
||||
## Development Workflow
|
||||
|
||||
### Adding New Features
|
||||
1. **Client Components**: Add to `src/components/`
|
||||
2. **Services**: Add to `src/services/`
|
||||
3. **API Routes**: Add to `src/app/api/`
|
||||
4. **Types**: Update `src/types/index.ts`
|
||||
|
||||
### Payment Integration
|
||||
- All payment logic in `src/services/solana.ts`
|
||||
- Verification logic in `src/utils/solanaVerify.ts`
|
||||
- Transaction signing via wallet adapters
|
||||
|
||||
### Registry Integration
|
||||
- Registry operations in `src/app/api/registry/route.ts`
|
||||
- Configuration in `src/config/index.ts`
|
||||
- LRN format: `lrn://{authority}/applications/{app-name}-{commit-hash}`
|
||||
|
||||
## DNS and Resource Naming
|
||||
|
||||
### DNS Name Generation
|
||||
Format: `{sanitized-repo-name}-{7-char-commit-hash}`
|
||||
- Sanitized to be DNS-compatible (alphanumeric + dashes)
|
||||
- Max 63 characters per DNS label
|
||||
- Always lowercase
|
||||
- Random salt removed (commit hash provides uniqueness)
|
||||
|
||||
### LRN (Laconic Resource Name) Format
|
||||
Format: `lrn://{authority}/applications/{app-name}-{commit-hash}`
|
||||
- Matches DNS naming for consistency
|
||||
- Includes both versioned and unversioned mappings
|
||||
|
||||
## Transaction Flow Details
|
||||
|
||||
### Solana Payment Verification
|
||||
- 5-minute transaction window
|
||||
- Amount verification against configured payment
|
||||
- Recipient address validation
|
||||
- Replay attack protection (TODO: implement used transaction tracking)
|
||||
|
||||
### Laconic Registry Operations
|
||||
1. **ApplicationRecord**: Repository metadata and commit info
|
||||
2. **Name Mappings**: LRN pointing to application record
|
||||
3. **ApplicationDeploymentRequest**: Deployment configuration with payment proof
|
||||
|
||||
## Common Development Tasks
|
||||
|
||||
### Adding New Wallet Support
|
||||
1. Update `SolanaWalletType` in `src/types/index.ts`
|
||||
2. Add wallet logic to `src/services/solana.ts`
|
||||
3. Update `SolanaConnect.tsx` component
|
||||
|
||||
### Modifying Payment Verification
|
||||
- Update logic in `src/utils/solanaVerify.ts`
|
||||
- Consider replay protection requirements
|
||||
- Test with various transaction types
|
||||
|
||||
### Registry Schema Changes
|
||||
- Update record structures in `src/app/api/registry/route.ts`
|
||||
- Ensure compatibility with Laconic Registry schema
|
||||
- Test with registry validation
|
||||
|
||||
## Deployment Considerations
|
||||
|
||||
### Production Setup
|
||||
- All environment variables must be set
|
||||
- HTTPS required for wallet connections
|
||||
- Consider rate limiting for API routes
|
||||
- Monitor gas fees and LNT transfer costs
|
||||
|
||||
### Docker Deployment
|
||||
- Dockerfile provided in project root
|
||||
- Build optimization for Next.js production
|
||||
- Environment variables via docker-compose or k8s secrets
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
- **Wallet connection fails**: Check HTTPS and wallet extension
|
||||
- **Transaction verification fails**: Check RPC URL and transaction timing
|
||||
- **Registry errors**: Verify all REGISTRY_* environment variables
|
||||
- **LNT transfer fails**: Check private key balance and permissions
|
||||
|
||||
### Debug Tools
|
||||
- Browser console for client-side debugging
|
||||
- Server logs for API route debugging
|
||||
- Solana Explorer for transaction verification
|
||||
- Registry CLI for direct registry interactions
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
- Manual testing with real wallets and tokens
|
||||
- Use testnet/devnet for development
|
||||
- Test transaction verification edge cases
|
||||
- Verify registry record creation and name mappings
|
||||
|
||||
## Related Projects
|
||||
|
||||
This application references:
|
||||
- `snowballtools-base/packages/backend/src/registry.ts`
|
||||
- Original `hosted-frontends/deploy-atom.sh`
|
||||
- Laconic Registry CLI tools
|
||||
|
||||
## Security Considerations
|
||||
|
||||
- Private keys stored securely server-side only
|
||||
- Transaction replay protection implemented
|
||||
- Input validation on all user inputs
|
||||
- Secure RPC endpoint usage
|
||||
- Wallet signature verification
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
- Implement transaction replay protection database
|
||||
- Add support for custom deployment configurations
|
||||
- Enhanced error recovery and retry logic
|
||||
- Monitoring and analytics integration
|
||||
- Multi-chain payment support
|
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
|
||||
|
||||
- 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
|
||||
- Transaction verification
|
||||
- Solana transaction verification with replay protection
|
||||
- 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
|
||||
- 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
|
||||
- npm or yarn
|
||||
- Keplr wallet browser extension
|
||||
- Solana wallet browser extension (Phantom or Solflare)
|
||||
- Access to a Laconic Registry node
|
||||
|
||||
## Environment Variables
|
||||
@ -29,10 +31,12 @@ cp .env.local.example .env.local
|
||||
Required environment variables:
|
||||
|
||||
Client-side (must be prefixed with NEXT_PUBLIC_):
|
||||
- `NEXT_PUBLIC_RECIPIENT_ADDRESS` - The Cosmos address that will receive ATOM payments
|
||||
- `NEXT_PUBLIC_COSMOS_RPC_URL` - The RPC URL for the Cosmos blockchain (used by Keplr for transactions)
|
||||
- `NEXT_PUBLIC_COSMOS_API_URL` - The REST API URL for the Cosmos blockchain (used for transaction queries)
|
||||
- `NEXT_PUBLIC_COSMOS_CHAIN_ID` - The chain ID for Keplr wallet (e.g., cosmoshub-4)
|
||||
- `NEXT_PUBLIC_SOLANA_RPC_URL` - The RPC URL for the Solana blockchain
|
||||
- `NEXT_PUBLIC_SOLANA_TOKEN_MINT_ADDRESS` - The mint address of the SPL token to accept
|
||||
- `NEXT_PUBLIC_SOLANA_TOKEN_RECIPIENT_ADDRESS` - The Solana address that will receive token payments
|
||||
- `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_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_BOND_ID` - The bond ID to use for Laconic Registry records
|
||||
- `REGISTRY_AUTHORITY` - The authority for Laconic Registry LRNs
|
||||
- `REGISTRY_USER_KEY` - The private key for Laconic Registry transactions
|
||||
- `APP_NAME` - The name of the application (used in record creation)
|
||||
- `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, defaults to "gor-deploy")
|
||||
- `DEPLOYER_LRN` - The LRN of the deployer
|
||||
|
||||
## Installation
|
||||
@ -74,24 +78,25 @@ npm start
|
||||
|
||||
## 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
|
||||
3. User completes payment in ATOM to a specified address
|
||||
4. The application verifies the transaction using the Cosmos RPC
|
||||
5. The application calls a server-side API route which creates records in the Laconic Registry
|
||||
6. The server generates a unique DNS name by adding a random salt to prevent name collisions
|
||||
7. Two records are created in the Laconic Registry:
|
||||
3. User completes payment in GOR tokens (or configured SPL token) to a specified Solana address
|
||||
4. The application verifies the Solana transaction with replay protection
|
||||
5. After payment verification, the server transfers LNT tokens from a prefilled account to the service provider
|
||||
6. The application calls a server-side API route which creates records in the Laconic Registry using the LNT transfer hash
|
||||
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 ApplicationDeploymentRequest linking the URL, DNS, and payment transaction
|
||||
- An ApplicationDeploymentRequest linking the URL, DNS, and payment details with external_payment metadata
|
||||
|
||||
### Architecture
|
||||
|
||||
This application uses a hybrid client/server approach:
|
||||
|
||||
- Client-side: Handles the user interface, Keplr wallet integration, and transaction verification
|
||||
- Server-side: Next.js API route handles the communication with the Laconic Registry
|
||||
- Client-side: Handles the user interface, Solana wallet integration, and transaction verification
|
||||
- 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
|
||||
|
||||
@ -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}
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
@ -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:
|
||||
- `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
|
||||
|
||||
@ -158,24 +163,32 @@ CMD ["npm", "start"]
|
||||
Build and run the Docker container:
|
||||
|
||||
```bash
|
||||
docker build -t atom-deploy .
|
||||
docker run -p 3000:3000 --env-file .env.production atom-deploy
|
||||
docker build -t gor-deploy .
|
||||
docker run -p 3000:3000 --env-file .env.production gor-deploy
|
||||
```
|
||||
|
||||
## Known Issues
|
||||
|
||||
- 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.
|
||||
- The Keplr wallet integration requires HTTPS in production environments.
|
||||
- Ensure that your Solana RPC endpoint supports CORS for client-side requests.
|
||||
- Solana wallet integrations require HTTPS in production environments.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Keplr Wallet Issues
|
||||
### Solana Wallet Issues
|
||||
|
||||
- **Keplr not detecting**: Install the Keplr 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.
|
||||
- **Wallet not detecting**: Install the Phantom or Solflare browser extension and refresh the page.
|
||||
- **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
|
||||
|
||||
- **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
|
75
deploy/README.md
Normal file
75
deploy/README.md
Normal file
@ -0,0 +1,75 @@
|
||||
# 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:
|
||||
|
||||
```bash
|
||||
curl -s https://git.vdb.to/LaconicNetwork/gor-deploy/src/branch/main/.env.example -o .app.env
|
||||
|
||||
# Fill in the required values
|
||||
nano .app.env
|
||||
```
|
||||
|
||||
## Run
|
||||
|
||||
### gor-deploy
|
||||
|
||||
* Deploy `gor-deploy` App:
|
||||
```bash
|
||||
# In gor-deploy/deploy dir
|
||||
docker run -it \
|
||||
-v ./:/app/deploy -w /app/deploy \
|
||||
-e DEPLOYMENT_DNS=gor-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
|
128
deploy/deploy.sh
Executable file
128
deploy/deploy.sh
Executable file
@ -0,0 +1,128 @@
|
||||
#!/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
|
||||
BRANCH_NAME="main"
|
||||
LATEST_HASH=$(git ls-remote $REPO_URL refs/heads/$BRANCH_NAME | awk '{print $1}')
|
||||
|
||||
PACKAGE_VERSION=$(curl -s $REPO_URL/raw/branch/$BRANCH_NAME/package.json | jq -r .version)
|
||||
|
||||
APP_NAME=gor-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 */
|
||||
typescript: {
|
||||
// !! 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",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
@ -11,8 +11,11 @@
|
||||
"dependencies": {
|
||||
"@cerc-io/registry-sdk": "^0.2.11",
|
||||
"@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",
|
||||
"big.js": "^6.2.2",
|
||||
"bn.js": "^5.2.2",
|
||||
"next": "15.3.1",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0"
|
||||
|
@ -1,120 +1,25 @@
|
||||
import BN from 'bn.js';
|
||||
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 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
|
||||
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
|
||||
const extractRepoInfo = (url: string): { repoName: string, repoUrl: string, provider: string } => {
|
||||
try {
|
||||
@ -202,7 +107,7 @@ const fetchLatestCommitHash = async (repoUrl: string, provider: string): Promise
|
||||
};
|
||||
|
||||
// Registry transaction retry helper
|
||||
const registryTransactionWithRetry = async (
|
||||
export const registryTransactionWithRetry = async (
|
||||
txFn: () => Promise<unknown>,
|
||||
maxRetries = 3,
|
||||
delay = 1000
|
||||
@ -225,15 +130,37 @@ const registryTransactionWithRetry = async (
|
||||
throw lastError;
|
||||
};
|
||||
|
||||
let connection: Connection;
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
if (!connection) {
|
||||
connection = new Connection(SOLANA_RPC_URL);
|
||||
}
|
||||
|
||||
// First check if the request body is valid JSON
|
||||
let url, txHash;
|
||||
let url, txHash, senderPublicKey;
|
||||
|
||||
try {
|
||||
const body = await request.json();
|
||||
url = body.url;
|
||||
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) {
|
||||
return NextResponse.json({
|
||||
status: 'error',
|
||||
@ -241,40 +168,62 @@ export async function POST(request: NextRequest) {
|
||||
}, { status: 400 });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
|
||||
return NextResponse.json({
|
||||
status: 'error',
|
||||
message: 'Invalid JSON in request body'
|
||||
}, { status: 400 });
|
||||
}
|
||||
|
||||
// First, verify the ATOM payment before doing anything else
|
||||
console.log('Step 0: Verifying ATOM payment...');
|
||||
const paymentVerificationResult = await verifyAtomPayment(txHash);
|
||||
// Verify Solana payment
|
||||
console.log('Step 0: Verifying Solana token payment...');
|
||||
const paymentAmount = parseInt(process.env.NEXT_PUBLIC_SOLANA_PAYMENT_AMOUNT!);
|
||||
const decimals = parseInt(process.env.NEXT_PUBLIC_SOLANA_TOKEN_DECIMALS!);
|
||||
|
||||
if (!paymentVerificationResult.valid) {
|
||||
console.error('ATOM payment verification failed:', paymentVerificationResult.reason);
|
||||
const fullAmount = paymentAmount * Math.pow(10, decimals);
|
||||
const tokenAmount = new BN(fullAmount);
|
||||
const solanaPaymentResult = await verifyUnusedSolanaPayment(connection, txHash, tokenAmount);
|
||||
|
||||
if (!solanaPaymentResult.valid) {
|
||||
console.error('Solana token payment verification failed:', solanaPaymentResult.reason);
|
||||
return NextResponse.json({
|
||||
status: 'error',
|
||||
message: `Payment verification failed: ${paymentVerificationResult.reason}`
|
||||
message: `Payment verification failed: ${solanaPaymentResult.reason}`
|
||||
}, { status: 400 });
|
||||
}
|
||||
|
||||
console.log('ATOM payment verified successfully:', {
|
||||
amount: paymentVerificationResult.amount,
|
||||
sender: paymentVerificationResult.sender
|
||||
console.log('Solana token payment verified successfully:', {
|
||||
amount: solanaPaymentResult.amount,
|
||||
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 = [
|
||||
'REGISTRY_CHAIN_ID',
|
||||
'REGISTRY_GQL_ENDPOINT',
|
||||
'REGISTRY_RPC_ENDPOINT',
|
||||
'REGISTRY_BOND_ID',
|
||||
'REGISTRY_AUTHORITY',
|
||||
'REGISTRY_USER_KEY',
|
||||
'REGISTRY_USER_KEY', // This is the same as the prefilled account for LNT transfers
|
||||
'DEPLOYER_LRN',
|
||||
'NEXT_PUBLIC_RECIPIENT_ADDRESS',
|
||||
'NEXT_PUBLIC_COSMOS_API_URL'
|
||||
'NEXT_PUBLIC_SOLANA_TOKEN_MINT_ADDRESS',
|
||||
'NEXT_PUBLIC_SOLANA_TOKEN_RECIPIENT_ADDRESS'
|
||||
];
|
||||
|
||||
for (const envVar of requiredEnvVars) {
|
||||
@ -302,17 +251,9 @@ export async function POST(request: NextRequest) {
|
||||
// Sanitize the app name to ensure it's DNS-compatible (only alphanumeric and dashes)
|
||||
const sanitizedAppName = appName.replace(/[^a-zA-Z0-9-]/g, '-').toLowerCase();
|
||||
|
||||
// Generate a random salt (6 alphanumeric characters) to prevent name collisions
|
||||
const generateSalt = (): string => {
|
||||
const chars = 'abcdefghijklmnopqrstuvwxyz0123456789';
|
||||
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})`);
|
||||
// Create DNS name in format: app_name-shortcommithash
|
||||
const dnsName = `${sanitizedAppName}-${shortHash}`;
|
||||
console.log(`DNS name: ${dnsName} (sanitized from: ${appName})`);
|
||||
|
||||
// Ensure the DNS name doesn't have consecutive dashes or start/end with a dash
|
||||
let cleanDnsName = dnsName
|
||||
@ -321,10 +262,9 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
// Ensure DNS name is valid (63 chars max per label, all lowercase, starts with a letter)
|
||||
if (cleanDnsName.length > 63) {
|
||||
// If too long, truncate but preserve both the commit hash and salt parts
|
||||
const suffixPart = `-${shortHash}-${salt}`;
|
||||
const maxAppNameLength = 63 - suffixPart.length;
|
||||
cleanDnsName = sanitizedAppName.substring(0, maxAppNameLength) + suffixPart;
|
||||
// If too long, truncate but preserve both the commit hash
|
||||
const maxAppNameLength = 63 - shortHash.length;
|
||||
cleanDnsName = sanitizedAppName.substring(0, maxAppNameLength) + shortHash;
|
||||
}
|
||||
|
||||
// If the DNS name ended up empty (unlikely) or doesn't start with a letter (possible),
|
||||
@ -333,22 +273,10 @@ export async function POST(request: NextRequest) {
|
||||
cleanDnsName = `app-${cleanDnsName}`;
|
||||
}
|
||||
|
||||
console.log(`Final DNS name with salt: ${cleanDnsName}`);
|
||||
console.log(`Final DNS name: ${cleanDnsName}`);
|
||||
|
||||
// Set up Registry config
|
||||
const config = {
|
||||
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
|
||||
},
|
||||
};
|
||||
const config = getRegistryConfig()
|
||||
|
||||
console.log('Registry config:', {
|
||||
...config,
|
||||
@ -358,18 +286,13 @@ export async function POST(request: NextRequest) {
|
||||
const deployerLrn = process.env.DEPLOYER_LRN!;
|
||||
|
||||
// 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);
|
||||
|
||||
const registry = new Registry(
|
||||
config.gqlEndpoint,
|
||||
config.rpcEndpoint,
|
||||
{ chainId: config.chainId, gasPrice }
|
||||
);
|
||||
const registry = getRegistry()
|
||||
|
||||
// Create LRN for the application with commit hash and salt
|
||||
// We already have the salt from earlier, so we use it directly
|
||||
const lrn = `lrn://${config.authority}/applications/${appName}-${shortHash}-${salt}`;
|
||||
// Create LRN for the application with commit hash
|
||||
const lrn = `lrn://${config.authority}/applications/${appName}-${shortHash}`;
|
||||
|
||||
// Get current timestamp for the meta note
|
||||
const timestamp = new Date().toUTCString();
|
||||
@ -378,7 +301,7 @@ export async function POST(request: NextRequest) {
|
||||
console.log('Step 1: Publishing ApplicationRecord...');
|
||||
const 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',
|
||||
app_type: 'webapp',
|
||||
repository: [repoUrl],
|
||||
@ -386,12 +309,6 @@ export async function POST(request: NextRequest) {
|
||||
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);
|
||||
|
||||
// Publish the application record
|
||||
@ -405,7 +322,6 @@ export async function POST(request: NextRequest) {
|
||||
bondId: config.bondId,
|
||||
},
|
||||
config.privateKey,
|
||||
fee
|
||||
)
|
||||
) as { id?: string };
|
||||
|
||||
@ -437,7 +353,6 @@ export async function POST(request: NextRequest) {
|
||||
lrn
|
||||
},
|
||||
config.privateKey,
|
||||
fee
|
||||
)
|
||||
);
|
||||
console.log(`Set name mapping: ${lrn} -> ${applicationRecordId}`);
|
||||
@ -450,7 +365,6 @@ export async function POST(request: NextRequest) {
|
||||
lrn: `${lrn}@${fullHash}`
|
||||
},
|
||||
config.privateKey,
|
||||
fee
|
||||
)
|
||||
);
|
||||
console.log(`Set name mapping: ${lrn}@${fullHash} -> ${applicationRecordId}`);
|
||||
@ -468,21 +382,25 @@ export async function POST(request: NextRequest) {
|
||||
const deploymentRequestData = {
|
||||
type: 'ApplicationDeploymentRequest',
|
||||
version: '1.0.0',
|
||||
name: `${appName}-${shortHash}-${salt}`, // Update name to match application record
|
||||
application: lrn, // LRN already includes commit hash and salt
|
||||
name: `${appName}-${shortHash}`, // Update name to match application record
|
||||
application: lrn, // LRN already includes commit hash
|
||||
deployer: deployerLrn,
|
||||
dns: cleanDnsName,
|
||||
config: {
|
||||
env: {
|
||||
LACONIC_HOSTED_CONFIG_laconicd_chain_id: process.env.REGISTRY_CHAIN_ID || 'laconic-testnet-2'
|
||||
}
|
||||
// TODO: Add feature to set env config for app
|
||||
env: {}
|
||||
},
|
||||
meta: {
|
||||
note: `Added via ATOM-Deploy @ ${timestamp}`,
|
||||
note: `Added via ${process.env.NEXT_PUBLIC_SOLANA_TOKEN_SYMBOL}-Deploy @ ${timestamp}`,
|
||||
repository: repoUrl,
|
||||
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);
|
||||
@ -498,7 +416,6 @@ export async function POST(request: NextRequest) {
|
||||
bondId: config.bondId,
|
||||
},
|
||||
config.privateKey,
|
||||
fee
|
||||
)
|
||||
) as { id?: string };
|
||||
|
||||
|
@ -1,5 +1,6 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
|
||||
import "./globals.css";
|
||||
import ErrorBoundaryWrapper from "../components/ErrorBoundaryWrapper";
|
||||
|
||||
@ -14,8 +15,8 @@ const geistMono = Geist_Mono({
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Deploy Frontends using ATOM and Laconic",
|
||||
description: "Deploy URLs to Laconic Registry using ATOM payments",
|
||||
title: `Deploy Frontends using ${process.env.NEXT_PUBLIC_SOLANA_TOKEN_SYMBOL} and Laconic`,
|
||||
description: `Deploy URLs to Laconic Registry using ${process.env.NEXT_PUBLIC_SOLANA_TOKEN_SYMBOL} payments`,
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
|
@ -1,17 +1,24 @@
|
||||
'use client';
|
||||
|
||||
import { 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 { useCallback, useState } from 'react';
|
||||
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() {
|
||||
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 [showPaymentModal, setShowPaymentModal] = useState(false);
|
||||
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 [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleConnect = (address: string) => {
|
||||
setWalletAddress(address);
|
||||
const handleConnectWallet = () => {
|
||||
setShowWalletConnection(true);
|
||||
};
|
||||
|
||||
const handleUrlSubmit = (submittedUrl: string) => {
|
||||
@ -35,14 +42,17 @@ export default function Home() {
|
||||
setShowPaymentModal(true);
|
||||
};
|
||||
|
||||
const handlePaymentComplete = async (hash: string) => {
|
||||
const handlePaymentComplete = useCallback(async (hash: string) => {
|
||||
if (!solanaWalletState.publicKey || !url) {
|
||||
return
|
||||
}
|
||||
|
||||
setTxHash(hash);
|
||||
setShowPaymentModal(false);
|
||||
setStatus('creating');
|
||||
|
||||
try {
|
||||
// Create the Laconic Registry record (payment verification is done in the API)
|
||||
if (url) {
|
||||
const result = await createApplicationDeploymentRequest(url, hash);
|
||||
|
||||
if (result.status === 'success') {
|
||||
@ -73,12 +83,11 @@ export default function Home() {
|
||||
setStatus('error');
|
||||
setError(result.message || 'Failed to create record in Laconic Registry');
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
setStatus('error');
|
||||
setError(error instanceof Error ? error.message : 'An unknown error occurred');
|
||||
}
|
||||
};
|
||||
}, [solanaWalletState, url])
|
||||
|
||||
const handleClosePaymentModal = () => {
|
||||
setShowPaymentModal(false);
|
||||
@ -89,7 +98,7 @@ export default function Home() {
|
||||
<div style={{ background: 'var(--card-bg)', borderColor: 'var(--card-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)' }}>
|
||||
Deploy Frontends with ATOM and Laconic
|
||||
Deploy Frontends with {process.env.NEXT_PUBLIC_SOLANA_TOKEN_SYMBOL} + Laconic
|
||||
</h1>
|
||||
|
||||
<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>
|
||||
Connect Your Wallet
|
||||
</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>
|
||||
|
||||
<div className="mb-8 p-6 rounded-lg" style={{ background: 'var(--muted-light)', borderLeft: '4px solid var(--primary)', opacity: walletAddress ? '1' : '0.6' }}>
|
||||
) : (
|
||||
<SolanaConnect onConnect={(walletState) => setSolanaWalletState(walletState)} />
|
||||
)}
|
||||
</div>
|
||||
<div className="mb-8 p-6 rounded-lg" style={{
|
||||
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">
|
||||
<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>
|
||||
@ -109,7 +142,7 @@ export default function Home() {
|
||||
</h2>
|
||||
<URLForm
|
||||
onSubmit={handleUrlSubmit}
|
||||
disabled={!walletAddress || status === 'creating'}
|
||||
disabled={!solanaWalletState.connected || status === 'creating'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@ -137,12 +170,13 @@ export default function Home() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showPaymentModal && walletAddress && url && (
|
||||
{showPaymentModal && url && solanaWalletState.connected && (
|
||||
<PaymentModal
|
||||
isOpen={showPaymentModal}
|
||||
onClose={handleClosePaymentModal}
|
||||
url={url}
|
||||
onPaymentComplete={handlePaymentComplete}
|
||||
walletState={solanaWalletState}
|
||||
/>
|
||||
)}
|
||||
</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,64 +1,56 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { sendAtomPayment } from '@/services/keplr';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import BN from 'bn.js';
|
||||
import assert from 'assert';
|
||||
|
||||
interface PaymentModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
url: string;
|
||||
onPaymentComplete: (txHash: string) => void;
|
||||
}
|
||||
import { Connection } from '@solana/web3.js';
|
||||
|
||||
import { sendSolanaTokenPayment } from '@/services/solana';
|
||||
import { PaymentModalProps } from '@/types';
|
||||
|
||||
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({
|
||||
isOpen,
|
||||
onClose,
|
||||
url,
|
||||
onPaymentComplete,
|
||||
walletState,
|
||||
}: PaymentModalProps) {
|
||||
const [amount, setAmount] = useState('0.01');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
// Get recipient address from environment variables
|
||||
const recipientAddress = process.env.NEXT_PUBLIC_RECIPIENT_ADDRESS || 'cosmos1yourrealaddress';
|
||||
const connection = useMemo(() => new Connection(SOLANA_RPC_URL), [])
|
||||
|
||||
// Validate amount on change
|
||||
const handleAmountChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const value = e.target.value;
|
||||
setAmount(value);
|
||||
// Get configuration from environment variables directly
|
||||
const amount = parseInt(process.env.NEXT_PUBLIC_SOLANA_PAYMENT_AMOUNT!);
|
||||
const decimals = parseInt(process.env.NEXT_PUBLIC_SOLANA_TOKEN_DECIMALS!);
|
||||
|
||||
// Clear error when user types
|
||||
if (error) {
|
||||
setError('');
|
||||
}
|
||||
};
|
||||
|
||||
const handlePayment = async () => {
|
||||
// Validate amount before sending
|
||||
const parsedAmount = parseFloat(amount);
|
||||
if (isNaN(parsedAmount) || parsedAmount <= 0) {
|
||||
setError('Please enter a valid positive amount');
|
||||
return;
|
||||
}
|
||||
const recipientAddress = process.env.NEXT_PUBLIC_SOLANA_TOKEN_RECIPIENT_ADDRESS;
|
||||
|
||||
const handlePayment = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
const result = await sendAtomPayment(recipientAddress, amount);
|
||||
const fullAmount = amount * Math.pow(10, decimals);
|
||||
const tokenAmount = new BN(fullAmount);
|
||||
|
||||
if (result.status === 'success' && result.hash) {
|
||||
onPaymentComplete(result.hash);
|
||||
const result = await sendSolanaTokenPayment(connection, walletState.publicKey!, tokenAmount, walletState.walletType!);
|
||||
|
||||
if (result.success && result.transactionSignature) {
|
||||
onPaymentComplete(result.transactionSignature);
|
||||
} 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) {
|
||||
setError(error instanceof Error ? error.message : 'Payment failed. Please try again.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
}, [connection, walletState, amount, decimals, onPaymentComplete]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
@ -67,7 +59,9 @@ export default function PaymentModal({
|
||||
<div className="max-w-md w-full rounded-xl shadow-xl animate-appear"
|
||||
style={{ background: 'var(--card-bg)', border: '1px solid 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 className="p-6 space-y-6">
|
||||
@ -87,27 +81,30 @@ export default function PaymentModal({
|
||||
|
||||
<div>
|
||||
<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>
|
||||
<div className="relative">
|
||||
<input
|
||||
id="amount"
|
||||
type="number"
|
||||
min="0.01"
|
||||
step="0.01"
|
||||
value={amount}
|
||||
onChange={handleAmountChange}
|
||||
disabled={true} // Fixed amount for Solana tokens
|
||||
className="w-full p-3 pr-12 rounded-md"
|
||||
style={{
|
||||
background: 'var(--card-bg)',
|
||||
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">
|
||||
<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>
|
||||
<p className="text-xs mt-1" style={{ color: 'var(--muted)' }}>
|
||||
Fixed amount required for deployment
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
@ -146,7 +143,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>
|
||||
</svg>
|
||||
)}
|
||||
{loading ? 'Processing...' : 'Pay with Keplr'}
|
||||
{loading ? 'Processing...' : 'Pay with Solana Wallet'}
|
||||
</button>
|
||||
</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,
|
||||
}: StatusDisplayProps) {
|
||||
// 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;
|
||||
|
||||
const StatusBadge = ({ type }: { type: 'creating' | 'success' | 'error' }) => {
|
||||
@ -125,11 +125,15 @@ export default function StatusDisplay({
|
||||
</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} />}
|
||||
{recordId && <InfoItem label="Laconic Deployment Request Record ID" value={recordId} />}
|
||||
{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>
|
||||
)}
|
||||
|
||||
|
@ -10,7 +10,7 @@ interface URLFormProps {
|
||||
export default function URLForm({ onSubmit, disabled }: URLFormProps) {
|
||||
// Get example URL from environment variables or use a default
|
||||
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 handleSubmit = (e: React.FormEvent) => {
|
||||
@ -49,7 +49,8 @@ export default function URLForm({ onSubmit, disabled }: URLFormProps) {
|
||||
// All validations passed
|
||||
setError('');
|
||||
onSubmit(trimmedUrl);
|
||||
} catch (_) {
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
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';
|
||||
|
||||
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 => {
|
||||
// Validate required environment variables
|
||||
const requiredEnvVars = [
|
||||
@ -25,22 +44,7 @@ export const getRegistryConfig = (): RegistryConfig => {
|
||||
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: process.env.REGISTRY_GAS_PRICE || '0.025',
|
||||
gasPrice: process.env.REGISTRY_GAS_PRICE || '0.001',
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
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';
|
||||
|
||||
export const createApplicationDeploymentRequest = async (
|
||||
url: string,
|
||||
txHash: string
|
||||
txHash: string,
|
||||
): Promise<CreateRecordResponse> => {
|
||||
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
|
||||
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 {
|
||||
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 {
|
||||
@ -13,33 +28,10 @@ export interface RegistryConfig {
|
||||
authority: string;
|
||||
privateKey: string;
|
||||
fee: {
|
||||
gas: string;
|
||||
fees: 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 {
|
||||
id: string;
|
||||
applicationRecordId?: string;
|
||||
@ -52,3 +44,31 @@ export interface CreateRecordResponse {
|
||||
status: 'success' | 'error';
|
||||
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;
|
||||
}
|
||||
|
131
src/utils/solanaVerify.ts
Normal file
131
src/utils/solanaVerify.ts
Normal file
@ -0,0 +1,131 @@
|
||||
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/*"]
|
||||
}
|
||||
},
|
||||
"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"]
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user