Compare commits
8 Commits
main
...
zach/atom-
Author | SHA1 | Date | |
---|---|---|---|
|
fc53cae044 | ||
|
84fd528619 | ||
|
fa4628a153 | ||
|
aa27f4ff18 | ||
|
15187227e9 | ||
|
429bb62cf1 | ||
|
a8be189229 | ||
|
50b1877e8c |
@ -15,6 +15,9 @@ RUN curl -LO https://git.vdb.to/cerc-io/stack-orchestrator/releases/download/lat
|
|||||||
chmod +x ./laconic-so && \
|
chmod +x ./laconic-so && \
|
||||||
mv ./laconic-so /usr/bin/laconic-so
|
mv ./laconic-so /usr/bin/laconic-so
|
||||||
|
|
||||||
|
# for testing, put so in the root of this repo prior to running `build-containers`
|
||||||
|
#COPY laconic-so /usr/bin/laconic-so
|
||||||
|
|
||||||
# laconic-registry-cli
|
# laconic-registry-cli
|
||||||
RUN npm config set @cerc-io:registry https://git.vdb.to/api/packages/cerc-io/npm/ && \
|
RUN npm config set @cerc-io:registry https://git.vdb.to/api/packages/cerc-io/npm/ && \
|
||||||
npm install -g @cerc-io/laconic-registry-cli && \
|
npm install -g @cerc-io/laconic-registry-cli && \
|
||||||
|
40
README.md
40
README.md
@ -1,7 +1,7 @@
|
|||||||
# webapp-deployment-status-api
|
# webapp-deployment-status-api
|
||||||
|
|
||||||
This API provides status information about webapp deployment requests and a mechanism for upload encrypted configuration
|
This API provides status information about webapp deployment requests and a mechanism for upload encrypted configuration
|
||||||
files used by those requests.
|
files used by those requests. It supports payments in both Laconic testnet (alnt) and Cosmos mainnet (ATOM).
|
||||||
|
|
||||||
## Build and Run
|
## Build and Run
|
||||||
|
|
||||||
@ -11,6 +11,22 @@ yarn build
|
|||||||
yarn start
|
yarn start
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
### Status Endpoints
|
||||||
|
- `GET /` - Get status of all deployment requests
|
||||||
|
- `GET /:id` - Get status of a specific deployment request
|
||||||
|
- `GET /:id/log` - Get logs for a specific deployment request
|
||||||
|
- `GET /log/:id` - Get logs for a specific deployment request (deprecated)
|
||||||
|
|
||||||
|
### Configuration Upload
|
||||||
|
- `POST /upload/config` - Upload encrypted configuration file
|
||||||
|
|
||||||
|
### ATOM Payment Verification
|
||||||
|
- `POST /verify/atom-payment` - Verify a Cosmos ATOM payment transaction
|
||||||
|
- Request body: `{ "txHash": "string", "minAmount": number (optional), "markAsUsed": boolean (optional) }`
|
||||||
|
- Response: `{ "valid": boolean, "reason": string (if invalid), "amount": number (if valid), "sender": string (if valid), "alreadyUsed": boolean (if transaction was already used) }`
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
### Keys
|
### Keys
|
||||||
@ -44,6 +60,8 @@ record:
|
|||||||
minimumPayment: 100alnt
|
minimumPayment: 100alnt
|
||||||
paymentAddress: laconic1clpc8smrhx5k25zmk3vwna8kddxrsem7a1jlry
|
paymentAddress: laconic1clpc8smrhx5k25zmk3vwna8kddxrsem7a1jlry
|
||||||
publicKey: mQGNBGbJUk0BDAC3j3CiaVtoEf1jrgtsjJnTA5u1a3BExP72mv0eE8y84TgY5rVcf ...
|
publicKey: mQGNBGbJUk0BDAC3j3CiaVtoEf1jrgtsjJnTA5u1a3BExP72mv0eE8y84TgY5rVcf ...
|
||||||
|
atomPaymentAddress: cosmos1clpc8smrhx5k25zmk3vwna8kddxrsem7a1jlry
|
||||||
|
minimumAtomPayment: 1
|
||||||
```
|
```
|
||||||
|
|
||||||
This record can most easily be created using `laconic-so publish-deployer-to-registry`.
|
This record can most easily be created using `laconic-so publish-deployer-to-registry`.
|
||||||
@ -54,7 +72,9 @@ laconic-so publish-deployer-to-registry \
|
|||||||
--api-url https://webapp-deployer-api.my.domain.com
|
--api-url https://webapp-deployer-api.my.domain.com
|
||||||
--public-key-file webapp-deployer-api.my.domain.com.pgp.pub \
|
--public-key-file webapp-deployer-api.my.domain.com.pgp.pub \
|
||||||
--lrn lrn://laconic/deployers/webapp-deployer-api.my.domain.com \
|
--lrn lrn://laconic/deployers/webapp-deployer-api.my.domain.com \
|
||||||
--min-required-payment 100
|
--min-required-payment 100 \
|
||||||
|
--atom-payment-address cosmos1clpc8smrhx5k25zmk3vwna8kddxrsem7a1jlry \
|
||||||
|
--min-atom-payment 1
|
||||||
```
|
```
|
||||||
|
|
||||||
This will create the record in the proper format and assign its LRN.
|
This will create the record in the proper format and assign its LRN.
|
||||||
@ -83,11 +103,13 @@ Users can now request deployment using the LRN of the deployer. This will allow
|
|||||||
|
|
||||||
1. Discover the API URL for config uploads.
|
1. Discover the API URL for config uploads.
|
||||||
1. Obtain the public key for encrypting config.
|
1. Obtain the public key for encrypting config.
|
||||||
1. See the minimum required payment.
|
1. See the minimum required payment (both alnt and ATOM if configured).
|
||||||
|
|
||||||
The request can be made using `laconic-so request-webapp-deployment`. This will handle encrypting and uploading the
|
The request can be made using `laconic-so request-webapp-deployment`. This will handle encrypting and uploading the
|
||||||
config automatically, as well as making a payment (if necessary).
|
config automatically, as well as making a payment (if necessary).
|
||||||
|
|
||||||
|
Payment can be made using either Laconic testnet (alnt) or Cosmos mainnet (ATOM). For Cosmos ATOM payments, you'll need to make the payment separately and provide the transaction hash.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
laconic-so request-webapp-deployment \
|
laconic-so request-webapp-deployment \
|
||||||
--laconic-config ~/.laconic/registry.yml \
|
--laconic-config ~/.laconic/registry.yml \
|
||||||
@ -95,6 +117,13 @@ laconic-so request-webapp-deployment \
|
|||||||
--app lrn://cerc-io/applications/webapp-hello-world@0.1.3 \
|
--app lrn://cerc-io/applications/webapp-hello-world@0.1.3 \
|
||||||
--env-file hello.env \
|
--env-file hello.env \
|
||||||
--make-payment auto
|
--make-payment auto
|
||||||
|
# OR for Cosmos ATOM payment
|
||||||
|
laconic-so request-webapp-deployment \
|
||||||
|
--laconic-config ~/.laconic/registry.yml \
|
||||||
|
--deployer lrn://laconic/deployers/webapp-deployer-api.my.domain.com \
|
||||||
|
--app lrn://cerc-io/applications/webapp-hello-world@0.1.3 \
|
||||||
|
--env-file hello.env \
|
||||||
|
--use-payment <your_cosmos_transaction_hash>
|
||||||
```
|
```
|
||||||
|
|
||||||
Alternatively, users can also use a deployment auction they created instead of making the payment to any specific deployer directly:
|
Alternatively, users can also use a deployment auction they created instead of making the payment to any specific deployer directly:
|
||||||
@ -135,6 +164,11 @@ LACONIC_CONFIG="/etc/config/registry.yml"
|
|||||||
LRN=lrn://laconic/deployers/webapp-deployer-api.my.domain.com
|
LRN=lrn://laconic/deployers/webapp-deployer-api.my.domain.com
|
||||||
CHECK_INTERVAL=15
|
CHECK_INTERVAL=15
|
||||||
|
|
||||||
|
# Cosmos ATOM payment configuration
|
||||||
|
ATOM_PAYMENT_ADDRESS="cosmos1clpc8smrhx5k25zmk3vwna8kddxrsem7a1jlry"
|
||||||
|
MIN_ATOM_PAYMENT=1
|
||||||
|
COSMOS_RPC_ENDPOINT="https://cosmos-rpc.example.com"
|
||||||
|
|
||||||
AUCTION_CHECK_INTERVAL=10
|
AUCTION_CHECK_INTERVAL=10
|
||||||
HANDLE_AUCTION_REQUESTS=true
|
HANDLE_AUCTION_REQUESTS=true
|
||||||
AUCTION_BID_AMOUNT=50000
|
AUCTION_BID_AMOUNT=50000
|
||||||
|
@ -36,8 +36,9 @@
|
|||||||
"author": "Jakub Synowiec <jsynowiec@users.noreply.github.com>",
|
"author": "Jakub Synowiec <jsynowiec@users.noreply.github.com>",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@cerc-io/laconic-sdk": "^0.1.15",
|
"@cerc-io/registry-sdk": "^0.2.11",
|
||||||
"@openpgp/web-stream-tools": "^0.1.3",
|
"@openpgp/web-stream-tools": "^0.1.3",
|
||||||
|
"axios": "^1.6.7",
|
||||||
"body-parser": "^1.20.2",
|
"body-parser": "^1.20.2",
|
||||||
"express": "^4.18.2",
|
"express": "^4.18.2",
|
||||||
"express-async-handler": "^1.2.0",
|
"express-async-handler": "^1.2.0",
|
||||||
|
178
src/atomPayments.ts
Normal file
178
src/atomPayments.ts
Normal file
@ -0,0 +1,178 @@
|
|||||||
|
import axios from 'axios';
|
||||||
|
import { Config } from './config.js';
|
||||||
|
|
||||||
|
// Store verified transaction hashes to prevent double-spending
|
||||||
|
const verifiedTransactions = new Map<string, {
|
||||||
|
timestamp: number,
|
||||||
|
amount: number,
|
||||||
|
sender: string,
|
||||||
|
used: boolean
|
||||||
|
}>();
|
||||||
|
|
||||||
|
// Periodically clean up old verified transactions (older than 24 hours)
|
||||||
|
setInterval(() => {
|
||||||
|
const now = Date.now();
|
||||||
|
for (const [hash, data] of verifiedTransactions.entries()) {
|
||||||
|
if (now - data.timestamp > 24 * 60 * 60 * 1000) {
|
||||||
|
verifiedTransactions.delete(hash);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 60 * 60 * 1000); // Clean up every hour
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verifies a Cosmos ATOM payment transaction
|
||||||
|
* @param txHash The transaction hash to verify
|
||||||
|
* @param requiredAmount The minimum required amount of ATOM
|
||||||
|
* @param markAsUsed Set to true to mark this transaction as used (preventing double-spending)
|
||||||
|
* @returns An object with verification result and details
|
||||||
|
*/
|
||||||
|
export async function verifyAtomPayment(
|
||||||
|
txHash: string,
|
||||||
|
requiredAmount = Config.MIN_ATOM_PAYMENT,
|
||||||
|
markAsUsed = false,
|
||||||
|
timeWindowMinutes = 5
|
||||||
|
): Promise<{
|
||||||
|
valid: boolean,
|
||||||
|
reason?: string,
|
||||||
|
amount?: string,
|
||||||
|
sender?: string,
|
||||||
|
alreadyUsed?: boolean
|
||||||
|
}> {
|
||||||
|
if (!Config.ATOM_PAYMENT_ADDRESS) {
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
reason: 'ATOM_PAYMENT_ADDRESS not configured'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Config.COSMOS_RPC_ENDPOINT) {
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
reason: 'COSMOS_RPC_ENDPOINT not configured'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if we've already verified this transaction
|
||||||
|
if (verifiedTransactions.has(txHash)) {
|
||||||
|
const txData = verifiedTransactions.get(txHash);
|
||||||
|
|
||||||
|
// If the transaction is marked as used and we're not just checking
|
||||||
|
if (txData.used && !markAsUsed) {
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
reason: 'Transaction has already been used for a deployment',
|
||||||
|
alreadyUsed: true
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we're marking as used
|
||||||
|
if (markAsUsed) {
|
||||||
|
txData.used = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return cached verification result
|
||||||
|
return {
|
||||||
|
valid: true,
|
||||||
|
amount: `${txData.amount}uatom`,
|
||||||
|
sender: txData.sender,
|
||||||
|
alreadyUsed: txData.used
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Fetch transaction from the Cosmos RPC endpoint
|
||||||
|
const response = await axios.get(
|
||||||
|
`${Config.COSMOS_RPC_ENDPOINT}/cosmos/tx/v1beta1/txs/${txHash}`
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.data || !response.data.tx || !response.data.tx_response) {
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
reason: 'Invalid transaction data from RPC 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 = timeWindowMinutes * 60 * 1000;
|
||||||
|
|
||||||
|
if (timeDiffMs > timeWindowMs) {
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
reason: `Transaction is older than ${timeWindowMinutes} 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 === Config.ATOM_PAYMENT_ADDRESS) {
|
||||||
|
for (const coin of msg.amount) {
|
||||||
|
if (coin.denom === 'uatom') {
|
||||||
|
// Get the amount in uatom
|
||||||
|
paymentAmountUAtom = coin.amount;
|
||||||
|
|
||||||
|
// Extract the required amount as an integer from the format like "10uatom"
|
||||||
|
const requiredAmountUAtom = parseInt(requiredAmount.replace(/[^0-9]/g, ''));
|
||||||
|
|
||||||
|
if (parseInt(paymentAmountUAtom) >= requiredAmountUAtom) {
|
||||||
|
foundValidPayment = true;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!foundValidPayment) {
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
reason: `Payment amount (${paymentAmountUAtom || '0'}uatom) is less than required (${requiredAmount}) or not sent to the correct address`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache the verification result
|
||||||
|
verifiedTransactions.set(txHash, {
|
||||||
|
timestamp: Date.now(),
|
||||||
|
amount: parseInt(paymentAmountUAtom),
|
||||||
|
sender,
|
||||||
|
used: markAsUsed
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
valid: true,
|
||||||
|
amount: `${paymentAmountUAtom}uatom`,
|
||||||
|
sender,
|
||||||
|
alreadyUsed: markAsUsed
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error verifying ATOM payment:', error);
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
reason: `Failed to verify transaction: ${error.message || 'Unknown error'}`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
@ -1,7 +1,7 @@
|
|||||||
import yaml from 'js-yaml';
|
import yaml from 'js-yaml';
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import {Registry} from '@cerc-io/laconic-sdk';
|
import {Registry} from '@cerc-io/registry-sdk';
|
||||||
|
|
||||||
const loadConfigFile = (configFilePath: string): any => {
|
const loadConfigFile = (configFilePath: string): any => {
|
||||||
const resolvedFilePath = path.resolve(process.cwd(), configFilePath);
|
const resolvedFilePath = path.resolve(process.cwd(), configFilePath);
|
||||||
@ -22,6 +22,10 @@ export const Config = {
|
|||||||
UPLOAD_MAX_SIZE: process.env.BUILD_LOGS || '1MB',
|
UPLOAD_MAX_SIZE: process.env.BUILD_LOGS || '1MB',
|
||||||
OPENPGP_PASSPHRASE: process.env.OPENPGP_PASSPHRASE,
|
OPENPGP_PASSPHRASE: process.env.OPENPGP_PASSPHRASE,
|
||||||
OPENPGP_PRIVATE_KEY_FILE: process.env.OPENPGP_PRIVATE_KEY_FILE,
|
OPENPGP_PRIVATE_KEY_FILE: process.env.OPENPGP_PRIVATE_KEY_FILE,
|
||||||
|
// Cosmos ATOM payment configuration
|
||||||
|
ATOM_PAYMENT_ADDRESS: process.env.ATOM_PAYMENT_ADDRESS || '',
|
||||||
|
MIN_ATOM_PAYMENT: process.env.MIN_ATOM_PAYMENT || '1000000uatom',
|
||||||
|
COSMOS_RPC_ENDPOINT: process.env.COSMOS_RPC_ENDPOINT || '',
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getRegistry = (): Registry => {
|
export const getRegistry = (): Registry => {
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import {getRegistry} from './config.js';
|
import {getRegistry} from './config.js';
|
||||||
import {Registry} from '@cerc-io/laconic-sdk';
|
import {Registry} from '@cerc-io/registry-sdk';
|
||||||
|
|
||||||
import stringify from 'json-stable-stringify';
|
import stringify from 'json-stable-stringify';
|
||||||
import {createHash} from 'crypto';
|
import {createHash} from 'crypto';
|
||||||
@ -25,10 +25,12 @@ export class RequestStatus {
|
|||||||
public url?: string;
|
public url?: string;
|
||||||
public deployment?: string;
|
public deployment?: string;
|
||||||
public lastUpdate?: string;
|
public lastUpdate?: string;
|
||||||
|
public atomPaymentVerified?: boolean;
|
||||||
|
|
||||||
constructor(public id: string, public createTime: string,) {
|
constructor(public id: string, public createTime: string,) {
|
||||||
this.lastState = 'SUBMITTED';
|
this.lastState = 'SUBMITTED';
|
||||||
this.lastUpdate = this.createTime;
|
this.lastUpdate = this.createTime;
|
||||||
|
this.atomPaymentVerified = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -70,6 +72,8 @@ export class RegHelper {
|
|||||||
} else {
|
} else {
|
||||||
const records = await this.registry.getRecordsByIds([id]);
|
const records = await this.registry.getRecordsByIds([id]);
|
||||||
for (const r of records) {
|
for (const r of records) {
|
||||||
|
// Check if record is not null before accessing its properties
|
||||||
|
if (r && r.id) {
|
||||||
this.cache.set(r.id, r);
|
this.cache.set(r.id, r);
|
||||||
if (Array.isArray(r.names)) {
|
if (Array.isArray(r.names)) {
|
||||||
for (const n of r.names) {
|
for (const n of r.names) {
|
||||||
@ -77,7 +81,9 @@ export class RegHelper {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return records[0];
|
}
|
||||||
|
// Return the first non-null record, or null if none found
|
||||||
|
return records.find(r => r !== null) || null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -107,6 +113,9 @@ export class RegHelper {
|
|||||||
return [...records];
|
return [...records];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ATOM payment verification moved to background payment processor
|
||||||
|
// This method is no longer used to avoid blocking the status API
|
||||||
|
|
||||||
async deploymentRequestStatus(requestId?: string) {
|
async deploymentRequestStatus(requestId?: string) {
|
||||||
const requests: any[] = [];
|
const requests: any[] = [];
|
||||||
const deployments: any[] = [];
|
const deployments: any[] = [];
|
||||||
@ -114,6 +123,9 @@ export class RegHelper {
|
|||||||
type: 'ApplicationDeploymentRemovalRequest',
|
type: 'ApplicationDeploymentRemovalRequest',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// WebappDeployer records no longer needed for status display
|
||||||
|
// Payment verification is handled by background processor
|
||||||
|
|
||||||
if (requestId) {
|
if (requestId) {
|
||||||
const request = await this.getRecordById(requestId);
|
const request = await this.getRecordById(requestId);
|
||||||
if (request) {
|
if (request) {
|
||||||
@ -136,11 +148,13 @@ export class RegHelper {
|
|||||||
|
|
||||||
const deploymentsByRequest = new Map<string, any>();
|
const deploymentsByRequest = new Map<string, any>();
|
||||||
for (const d of deployments) {
|
for (const d of deployments) {
|
||||||
|
if (d && d.attributes?.request) {
|
||||||
deploymentsByRequest.set(d.attributes.request, d);
|
deploymentsByRequest.set(d.attributes.request, d);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
const removalsByRequest = new Map<string, any>();
|
const removalsByRequest = new Map<string, any>();
|
||||||
for (const rr of removalRequests) {
|
for (const rr of removalRequests) {
|
||||||
if (rr.attributes.request) {
|
if (rr && rr.attributes?.request) {
|
||||||
removalsByRequest.set(rr.attributes.request, rr);
|
removalsByRequest.set(rr.attributes.request, rr);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -149,9 +163,21 @@ export class RegHelper {
|
|||||||
|
|
||||||
const ret = [];
|
const ret = [];
|
||||||
for (const r of requests) {
|
for (const r of requests) {
|
||||||
|
// Skip null or invalid requests
|
||||||
|
if (!r || !r.id || !r.createTime) {
|
||||||
|
console.warn('Skipping invalid request:', r);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
const status = new RequestStatus(r.id, r.createTime);
|
const status = new RequestStatus(r.id, r.createTime);
|
||||||
ret.push(status);
|
ret.push(status);
|
||||||
|
|
||||||
|
// Skip if application attribute is missing
|
||||||
|
if (!r.attributes?.application) {
|
||||||
|
status.lastState = 'ERROR';
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
const app = await this.getRecord(r.attributes.application);
|
const app = await this.getRecord(r.attributes.application);
|
||||||
if (!app) {
|
if (!app) {
|
||||||
status.lastState = 'ERROR';
|
status.lastState = 'ERROR';
|
||||||
@ -161,6 +187,13 @@ export class RegHelper {
|
|||||||
status.app = r.attributes.application;
|
status.app = r.attributes.application;
|
||||||
const hostname = r.attributes.dns ?? generateHostnameForApp(app);
|
const hostname = r.attributes.dns ?? generateHostnameForApp(app);
|
||||||
|
|
||||||
|
// Payment verification is now handled by the background payment processor
|
||||||
|
// No longer blocking the status API with external verification calls
|
||||||
|
if (r.attributes.payment) {
|
||||||
|
// Just indicate that there is a payment hash, actual verification is done in background
|
||||||
|
status.atomPaymentVerified = false; // Will be updated by payment processor if needed
|
||||||
|
}
|
||||||
|
|
||||||
if (deploymentsByRequest.has(r.id)) {
|
if (deploymentsByRequest.has(r.id)) {
|
||||||
const deployment = deploymentsByRequest.get(r.id);
|
const deployment = deploymentsByRequest.get(r.id);
|
||||||
status.url = deployment.attributes.url;
|
status.url = deployment.attributes.url;
|
||||||
|
54
src/main.ts
54
src/main.ts
@ -6,6 +6,8 @@ import {Config} from './config.js';
|
|||||||
|
|
||||||
import {RegHelper} from './deployments.js';
|
import {RegHelper} from './deployments.js';
|
||||||
import {Uploader} from './upload.js';
|
import {Uploader} from './upload.js';
|
||||||
|
import {verifyAtomPayment} from './atomPayments.js';
|
||||||
|
import {PaymentProcessor} from './paymentProcessor.js';
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
@ -112,6 +114,53 @@ app.post('/upload/config', configUploadParser, async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// API endpoint to verify ATOM payments (with timeout to prevent blocking)
|
||||||
|
app.post('/verify/atom-payment', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { txHash, minAmount, markAsUsed } = req.body;
|
||||||
|
|
||||||
|
if (!txHash) {
|
||||||
|
return res.status(400).json({
|
||||||
|
valid: false,
|
||||||
|
reason: 'Transaction hash is required'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Config.ATOM_PAYMENT_ADDRESS) {
|
||||||
|
return res.status(400).json({
|
||||||
|
valid: false,
|
||||||
|
reason: 'ATOM_PAYMENT_ADDRESS not configured'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Config.COSMOS_RPC_ENDPOINT) {
|
||||||
|
return res.status(400).json({
|
||||||
|
valid: false,
|
||||||
|
reason: 'COSMOS_RPC_ENDPOINT not configured'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const requiredAmount = minAmount || Config.MIN_ATOM_PAYMENT;
|
||||||
|
|
||||||
|
// Add timeout to prevent blocking
|
||||||
|
const timeoutPromise = new Promise((_, reject) =>
|
||||||
|
setTimeout(() => reject(new Error('Verification timeout')), 10000)
|
||||||
|
);
|
||||||
|
|
||||||
|
const verificationPromise = verifyAtomPayment(txHash, requiredAmount, Boolean(markAsUsed), 5);
|
||||||
|
|
||||||
|
const result = await Promise.race([verificationPromise, timeoutPromise]);
|
||||||
|
|
||||||
|
res.json(result);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error verifying ATOM payment:', e);
|
||||||
|
res.status(500).json({
|
||||||
|
valid: false,
|
||||||
|
reason: `Server error: ${e.message || 'Unknown error'}`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// deprecated
|
// deprecated
|
||||||
app.get('/log/:id', async (req, res) => {
|
app.get('/log/:id', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
@ -128,6 +177,11 @@ app.get('/log/:id', async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Start the payment processor service
|
||||||
|
const paymentProcessor = new PaymentProcessor();
|
||||||
|
paymentProcessor.start();
|
||||||
|
|
||||||
app.listen(Config.LISTEN_PORT, Config.LISTEN_ADDR, () => {
|
app.listen(Config.LISTEN_PORT, Config.LISTEN_ADDR, () => {
|
||||||
console.log(`listening on ${Config.LISTEN_ADDR}:${Config.LISTEN_PORT}`);
|
console.log(`listening on ${Config.LISTEN_ADDR}:${Config.LISTEN_PORT}`);
|
||||||
|
console.log('Payment processor service started');
|
||||||
});
|
});
|
||||||
|
350
src/paymentProcessor.ts
Normal file
350
src/paymentProcessor.ts
Normal file
@ -0,0 +1,350 @@
|
|||||||
|
import { Registry, Account, parseGasAndFees, DEFAULT_GAS_ESTIMATION_MULTIPLIER } from '@cerc-io/registry-sdk';
|
||||||
|
import { DeliverTxResponse } from '@cosmjs/stargate';
|
||||||
|
import { Config } from './config.js';
|
||||||
|
import { verifyAtomPayment } from './atomPayments.js';
|
||||||
|
import { RegHelper } from './deployments.js';
|
||||||
|
import yaml from 'js-yaml';
|
||||||
|
import fs from 'fs';
|
||||||
|
|
||||||
|
interface ChainConfig {
|
||||||
|
name: string;
|
||||||
|
rpcUrl: string;
|
||||||
|
chainId: string;
|
||||||
|
minAmount: string;
|
||||||
|
paymentAddress: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface VerificationResult {
|
||||||
|
valid: boolean;
|
||||||
|
reason?: string;
|
||||||
|
amount?: string;
|
||||||
|
sender?: string;
|
||||||
|
chainUsed?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
class PaymentProcessor {
|
||||||
|
private regHelper: RegHelper;
|
||||||
|
private processedHashes: Set<string> = new Set();
|
||||||
|
private checkInterval: number = 30000; // 30 seconds
|
||||||
|
private lastProcessedTime: Date = new Date();
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.regHelper = new RegHelper();
|
||||||
|
// Only process requests from the last 10 minutes on startup
|
||||||
|
this.lastProcessedTime = new Date(Date.now() - 10 * 60 * 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start the payment processor service
|
||||||
|
*/
|
||||||
|
start(): void {
|
||||||
|
console.log('Payment processor started');
|
||||||
|
this.initializeChains();
|
||||||
|
this.processPayments();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Main processing loop
|
||||||
|
*/
|
||||||
|
private async processPayments(): Promise<void> {
|
||||||
|
try {
|
||||||
|
await this.processNewRequests();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error in payment processing:', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Schedule next run
|
||||||
|
setTimeout(() => this.processPayments(), this.checkInterval);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process new ApplicationDeploymentRequest records (only recent ones)
|
||||||
|
*/
|
||||||
|
private async processNewRequests(): Promise<void> {
|
||||||
|
try {
|
||||||
|
// Query for all ApplicationDeploymentRequest records
|
||||||
|
const allRequests = await this.regHelper.queryRecords({
|
||||||
|
type: 'ApplicationDeploymentRequest',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Filter to only process recent requests (created after lastProcessedTime)
|
||||||
|
const recentRequests = allRequests.filter(request => {
|
||||||
|
const createTime = new Date(request.createTime);
|
||||||
|
return createTime > this.lastProcessedTime;
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`Found ${allRequests.length} total deployment requests, processing ${recentRequests.length} recent ones`);
|
||||||
|
|
||||||
|
for (const request of recentRequests) {
|
||||||
|
await this.processRequest(request);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update lastProcessedTime to now
|
||||||
|
this.lastProcessedTime = new Date();
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error querying deployment requests:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process individual deployment request
|
||||||
|
*/
|
||||||
|
private async processRequest(request: any): Promise<void> {
|
||||||
|
const requestId = request.id;
|
||||||
|
const paymentHash = request.attributes.payment;
|
||||||
|
|
||||||
|
// Skip if no payment hash
|
||||||
|
if (!paymentHash) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip if already processed
|
||||||
|
if (this.processedHashes.has(paymentHash)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip if request already has a deployment (already successfully processed)
|
||||||
|
const existingDeployments = await this.regHelper.queryRecords({
|
||||||
|
type: 'ApplicationDeploymentRecord',
|
||||||
|
request: requestId
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existingDeployments.length > 0) {
|
||||||
|
console.log(`Skipping request ${requestId} - already has deployment`);
|
||||||
|
this.processedHashes.add(paymentHash);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Processing request ${requestId} with payment hash ${paymentHash}`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// First check if this is an LNT payment
|
||||||
|
const lntVerification = await this.verifyLNTPayment(paymentHash, request);
|
||||||
|
if (lntVerification.valid) {
|
||||||
|
console.log(`LNT payment verified for request ${requestId}`);
|
||||||
|
this.processedHashes.add(paymentHash);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If LNT verification failed, try ATOM verification
|
||||||
|
const atomVerification = await this.verifyATOMPayment(paymentHash, request);
|
||||||
|
if (atomVerification.valid) {
|
||||||
|
console.log(`ATOM payment verified for request ${requestId}, converting to LNT`);
|
||||||
|
|
||||||
|
// Send LNT from deployer account to deployer account
|
||||||
|
await this.sendLNTToDeployer(request);
|
||||||
|
|
||||||
|
this.processedHashes.add(paymentHash);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If both failed, log and continue (don't mark as processed to allow retries)
|
||||||
|
console.log(`Payment verification failed for request ${requestId}: LNT: ${lntVerification.reason}, ATOM: ${atomVerification.reason}`);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error processing request ${requestId}:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify LNT payment - delegated to stack-orchestrator
|
||||||
|
* Since stack-orchestrator already handles LNT verification efficiently,
|
||||||
|
* we only need to handle ATOM verification here
|
||||||
|
*/
|
||||||
|
private async verifyLNTPayment(_txHash: string, _request: any): Promise<VerificationResult> {
|
||||||
|
try {
|
||||||
|
// LNT verification is handled by stack-orchestrator
|
||||||
|
// We assume if it's not an ATOM payment, stack-orchestrator will handle it
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
reason: 'LNT verification delegated to stack-orchestrator'
|
||||||
|
};
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
reason: `LNT verification error: ${error.message}`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify ATOM payment with 5-minute time window
|
||||||
|
*/
|
||||||
|
private async verifyATOMPayment(txHash: string, request: any): Promise<VerificationResult> {
|
||||||
|
try {
|
||||||
|
// Skip if ATOM payment not configured
|
||||||
|
if (!Config.ATOM_PAYMENT_ADDRESS || !Config.COSMOS_RPC_ENDPOINT) {
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
reason: 'ATOM payment verification not configured'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the deployer record to check ATOM payment config
|
||||||
|
const deployer = await this.regHelper.getRecord(request.attributes.deployer);
|
||||||
|
if (!deployer) {
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
reason: 'Deployer record not found'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const atomPaymentAddress = deployer.attributes.atomPaymentAddress || Config.ATOM_PAYMENT_ADDRESS;
|
||||||
|
const minAtomPayment = deployer.attributes.minimumAtomPayment || Config.MIN_ATOM_PAYMENT;
|
||||||
|
|
||||||
|
if (!atomPaymentAddress) {
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
reason: 'ATOM payment address not configured for this deployer'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use existing ATOM verification with 5-minute time window
|
||||||
|
const result = await verifyAtomPayment(txHash, minAtomPayment, false);
|
||||||
|
|
||||||
|
if (result.valid) {
|
||||||
|
return {
|
||||||
|
valid: true,
|
||||||
|
amount: result.amount,
|
||||||
|
sender: result.sender,
|
||||||
|
chainUsed: 'ATOM'
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
reason: result.reason || 'ATOM payment verification failed'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
reason: `ATOM verification error: ${error.message}`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send LNT from deployer account to deployer account to satisfy stack-orchestrator
|
||||||
|
*/
|
||||||
|
private async sendLNTToDeployer(request: any): Promise<void> {
|
||||||
|
try {
|
||||||
|
// Get the deployer record
|
||||||
|
const deployer = await this.regHelper.getRecord(request.attributes.deployer);
|
||||||
|
if (!deployer) {
|
||||||
|
throw new Error('Deployer record not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const paymentAddress = deployer.attributes.paymentAddress;
|
||||||
|
const minPayment = deployer.attributes.minimumPayment || '0alnt';
|
||||||
|
|
||||||
|
// Extract amount number from string like "100alnt"
|
||||||
|
const amount = minPayment.replace(/[^0-9]/g, '') || '0';
|
||||||
|
|
||||||
|
console.log(`Sending ${minPayment} LNT from deployer to deployer (${paymentAddress}) for request ${request.id}`);
|
||||||
|
|
||||||
|
// Send LNT tokens using proper Registry SDK methods
|
||||||
|
const result = await this.sendLNTTokens(paymentAddress, amount);
|
||||||
|
|
||||||
|
console.log(`LNT transfer successful: ${result.transactionHash}`);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error sending LNT for request ${request.id}:`, error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send LNT tokens using Registry SDK
|
||||||
|
*/
|
||||||
|
private async sendLNTTokens(toAddress: string, amount: string): Promise<DeliverTxResponse> {
|
||||||
|
// Load laconic config to get registry details and private key
|
||||||
|
const configData = yaml.load(fs.readFileSync(Config.LACONIC_CONFIG, 'utf8')) as any;
|
||||||
|
const registryConfig = configData.services?.registry;
|
||||||
|
|
||||||
|
if (!registryConfig) {
|
||||||
|
throw new Error('Registry configuration not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get private key from environment or config
|
||||||
|
const privateKey = process.env.REGISTRY_USER_KEY || registryConfig.privateKey;
|
||||||
|
if (!privateKey) {
|
||||||
|
throw new Error('Registry private key not configured');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create account
|
||||||
|
const account = new Account(Buffer.from(privateKey, 'hex'));
|
||||||
|
await account.init();
|
||||||
|
|
||||||
|
// Create registry instance
|
||||||
|
const registry = new Registry(
|
||||||
|
registryConfig.gqlEndpoint,
|
||||||
|
registryConfig.rpcEndpoint,
|
||||||
|
{ chainId: registryConfig.chainId }
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get laconic client
|
||||||
|
const laconicClient = await registry.getLaconicClient(account);
|
||||||
|
|
||||||
|
// Calculate fees
|
||||||
|
const fee = registryConfig.gas && registryConfig.fees ?
|
||||||
|
parseGasAndFees(registryConfig.gas, registryConfig.fees) :
|
||||||
|
DEFAULT_GAS_ESTIMATION_MULTIPLIER;
|
||||||
|
|
||||||
|
// Send tokens from deployer account to deployer account
|
||||||
|
const txResponse: DeliverTxResponse = await laconicClient.sendTokens(
|
||||||
|
account.address, // from (deployer account)
|
||||||
|
toAddress, // to (same deployer account)
|
||||||
|
[{
|
||||||
|
denom: 'alnt', // LNT token denomination
|
||||||
|
amount // amount as string
|
||||||
|
}],
|
||||||
|
fee
|
||||||
|
);
|
||||||
|
|
||||||
|
return txResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get chain configurations for future extensibility
|
||||||
|
*/
|
||||||
|
private getChainConfigs(): ChainConfig[] {
|
||||||
|
const configs: ChainConfig[] = [];
|
||||||
|
|
||||||
|
// ATOM configuration
|
||||||
|
if (Config.ATOM_PAYMENT_ADDRESS && Config.COSMOS_RPC_ENDPOINT) {
|
||||||
|
configs.push({
|
||||||
|
name: 'ATOM',
|
||||||
|
rpcUrl: Config.COSMOS_RPC_ENDPOINT,
|
||||||
|
chainId: 'cosmoshub-4', // Could be made configurable
|
||||||
|
minAmount: Config.MIN_ATOM_PAYMENT,
|
||||||
|
paymentAddress: Config.ATOM_PAYMENT_ADDRESS,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Future chains can be added here
|
||||||
|
// if (Config.OSMO_PAYMENT_ADDRESS && Config.OSMO_RPC_ENDPOINT) {
|
||||||
|
// configs.push({
|
||||||
|
// name: 'OSMO',
|
||||||
|
// rpcUrl: Config.OSMO_RPC_ENDPOINT,
|
||||||
|
// chainId: 'osmosis-1',
|
||||||
|
// minAmount: Config.MIN_OSMO_PAYMENT,
|
||||||
|
// paymentAddress: Config.OSMO_PAYMENT_ADDRESS,
|
||||||
|
// });
|
||||||
|
// }
|
||||||
|
|
||||||
|
console.log(`Configured ${configs.length} payment chains`);
|
||||||
|
return configs;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize chain configurations on startup
|
||||||
|
*/
|
||||||
|
private initializeChains(): void {
|
||||||
|
const configs = this.getChainConfigs();
|
||||||
|
console.log(`Payment processor initialized with ${configs.length} supported chains`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export { PaymentProcessor };
|
Loading…
Reference in New Issue
Block a user