13 Commits
Author SHA1 Message Date
zramsay fc53cae044 idk claude 2025-06-26 11:43:31 -04:00
zramsay 84fd528619 dont process all previous requests on boot 2025-06-26 11:16:55 -04:00
zramsay fa4628a153 ts errors 2025-06-26 11:03:58 -04:00
zramsay aa27f4ff18 fix 2025-06-26 10:58:21 -04:00
zramsay 15187227e9 try 2025-06-26 10:45:32 -04:00
zramsay 429bb62cf1 uatom. string 2025-05-02 14:14:09 -04:00
zramsay a8be189229 use local laconic-so for testing 2025-05-01 07:51:35 -04:00
zramsay 50b1877e8c accept atom payments 2025-04-30 11:08:00 -04:00
prathamesh 6322ebd30f Pass k8s cluster IP when deploying webapps (#25)
Part of https://www.notion.so/Support-custom-domains-in-deploy-laconic-com-18aa6b22d4728067a44ae27090c02ce5 and cerc-io/snowballtools-base#47
Requires cerc-io/stack-orchestrator#963

Reviewed-on: cerc-io/webapp-deployment-status-api#25
Reviewed-by: ashwin <ashwin@noreply.git.vdb.to>
Co-authored-by: Prathamesh Musale <prathamesh.musale0@gmail.com>
Co-committed-by: Prathamesh Musale <prathamesh.musale0@gmail.com>
2025-02-04 13:27:17 +00:00
prathamesh 7f83bc6c89 Fix recreate-on-deploy flag in deploy command (#23)
Part of [Service provider auctions for web deployments](https://www.notion.so/Service-provider-auctions-for-web-deployments-104a6b22d47280dbad51d28aa3a91d75)

Reviewed-on: cerc-io/webapp-deployment-status-api#23
Co-authored-by: Prathamesh Musale <prathamesh.musale0@gmail.com>
Co-committed-by: Prathamesh Musale <prathamesh.musale0@gmail.com>
2024-10-29 07:08:11 +00:00
prathameshandAdw8 48af6dea2c Remove payment requirement for undeployments (#22)
Part of [Service provider auctions for web deployments](https://www.notion.so/Service-provider-auctions-for-web-deployments-104a6b22d47280dbad51d28aa3a91d75)

Co-authored-by: Adw8 <adwaitgharpure@gmail.com>
Reviewed-on: cerc-io/webapp-deployment-status-api#22
Co-authored-by: Prathamesh Musale <prathamesh@noreply.git.vdb.to>
Co-committed-by: Prathamesh Musale <prathamesh@noreply.git.vdb.to>
2024-10-29 06:27:01 +00:00
prathamesh cad9406579 Handle deployment auctions in a separate process (#21)
Part of [Service provider auctions for web deployments](https://www.notion.so/Service-provider-auctions-for-web-deployments-104a6b22d47280dbad51d28aa3a91d75) and cerc-io/stack-orchestrator#948
Requires cerc-io/stack-orchestrator#957

Reviewed-on: cerc-io/webapp-deployment-status-api#21
Reviewed-by: ashwin <ashwin@noreply.git.vdb.to>
Co-authored-by: Prathamesh Musale <prathamesh.musale0@gmail.com>
Co-committed-by: Prathamesh Musale <prathamesh.musale0@gmail.com>
2024-10-25 09:00:46 +00:00
prathamesh 16276e80d0 Handle deployment auction requests (#19)
Part of [Service provider auctions for web deployments](https://www.notion.so/Service-provider-auctions-for-web-deployments-104a6b22d47280dbad51d28aa3a91d75) and cerc-io/stack-orchestrator#948
Requires cerc-io/stack-orchestrator#950

Reviewed-on: cerc-io/webapp-deployment-status-api#19
Co-authored-by: Prathamesh Musale <prathamesh.musale0@gmail.com>
Co-committed-by: Prathamesh Musale <prathamesh.musale0@gmail.com>
2024-10-21 07:07:06 +00:00
12 changed files with 818 additions and 35 deletions
+2 -1
View File
@@ -9,4 +9,5 @@ CLEAN_CONTAINERS=false
SYSTEM_PRUNE=false
WEBAPP_IMAGE_PRUNE=true
CHECK_INTERVAL=5
FQDN_POLICY="allow"
FQDN_POLICY="allow"
DEPLOYMENT_IP="k8s.cluster.ip.address"
+5 -2
View File
@@ -15,6 +15,9 @@ RUN curl -LO https://git.vdb.to/cerc-io/stack-orchestrator/releases/download/lat
chmod +x ./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
RUN npm config set @cerc-io:registry https://git.vdb.to/api/packages/cerc-io/npm/ && \
npm install -g @cerc-io/laconic-registry-cli && \
@@ -44,5 +47,5 @@ COPY . /app/
WORKDIR /app/
RUN rm -rf node_modules && yarn && yarn clean && yarn build:release
COPY run.sh .
CMD ["./run.sh"]
COPY scripts .
CMD ["./scripts/run.sh"]
+102 -20
View File
@@ -1,15 +1,31 @@
# webapp-deployment-status-api
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
```bash
yarn
yarn build
yarn start
```
$ yarn
$ yarn build
$ 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
@@ -20,22 +36,22 @@ On upload, the configuration is temporarily decrypted for validation, but stored
To create and export a key in the necessary format use:
```
```bash
# Create a key
$ gpg --batch --passphrase "SECRET" --quick-generate-key webapp-deployer-api.my.domain.com default default never
gpg --batch --passphrase "SECRET" --quick-generate-key webapp-deployer-api.my.domain.com default default never
# Export the public key
$ gpg --export webapp-deployer-api.my.domain.com > webapp-deployer-api.my.domain.com.pgp.pub
gpg --export webapp-deployer-api.my.domain.com > webapp-deployer-api.my.domain.com.pgp.pub
# Export the private key
$ gpg --export-secret-keys webapp-deployer-api.my.domain.com > webapp-deployer-api.my.domain.com.pgp.key
gpg --export-secret-keys webapp-deployer-api.my.domain.com > webapp-deployer-api.my.domain.com.pgp.key
```
### Create the Deployer Record
Every webapp deployer should have `WebappDeployer` record in the registry which looks something like:
```
```yml
record:
type: WebappDeployer
version: 1.0.0
@@ -44,20 +60,42 @@ record:
minimumPayment: 100alnt
paymentAddress: laconic1clpc8smrhx5k25zmk3vwna8kddxrsem7a1jlry
publicKey: mQGNBGbJUk0BDAC3j3CiaVtoEf1jrgtsjJnTA5u1a3BExP72mv0eE8y84TgY5rVcf ...
atomPaymentAddress: cosmos1clpc8smrhx5k25zmk3vwna8kddxrsem7a1jlry
minimumAtomPayment: 1
```
This record can most easily be created using `laconic-so publish-deployer-to-registry`.
```
$ laconic-so publish-deployer-to-registry \
```bash
laconic-so publish-deployer-to-registry \
--laconic-config ~/.laconic/registry.yml \
--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 \
--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.
### Publish Deployment Auction
Users can optionally create an auction for app deployment with desired number of providers and max price they are willing to pay for a deployment:
```bash
laconic-so publish-deployment-auction \
--laconic-config ./config.yml \
--app lrn://cerc-io/applications/webapp-hello-world@0.1.3 \
--commits-duration 3600 \
--reveals-duration 3600 \
--commit-fee 10000 \
--reveal-fee 10000 \
--max-price 5000000 \
--num-providers 3
```
This will create a `provider` auction with given params and publish a deployment auction record.
### Request Deployment
@@ -65,23 +103,56 @@ Users can now request deployment using the LRN of the deployer. This will allow
1. Discover the API URL for config uploads.
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).
```
$ laconic-so request-webapp-deployment \
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
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 \
--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:
```bash
laconic-so request-webapp-deployment \
--laconic-config ~/.laconic/registry.yml \
--app lrn://cerc-io/applications/webapp-hello-world@0.1.3 \
--env-file hello.env \
--auction-id 4c9701c22651e143202e991056b6e7649853acc5bc0e97e3a98e09c9f3355909
```
This creates deployment requests targeted towards all the deployers who have won the auction. Similar to requests with payments, the config is automatically encrypted and uploaded to all the deployers.
### Request Undeployment
Users can also request removal of an existing deployment using the deployment record id:
```bash
laconic-so request-webapp-undeployment \
--laconic-config ~/.laconic/registry.yml \
--deployer lrn://laconic/deployers/webapp-deployer-api.my.domain.com \
--deployment bafyreigeopr72dmp6rhvnomgdz3cljbqzhh75epcrigit7ue6i6vjullme \
--make-payment auto
```
### Example Config
```
```bash
UPLOAD_DIRECTORY="/srv/uploads/config"
UPLOAD_MAX_SIZE="1MB"
DEPLOYER_STATE="/srv/deployments/autodeploy.state"
@@ -90,4 +161,15 @@ BUILD_LOGS="/srv/logs"
OPENPGP_PASSPHRASE="SECRET"
OPENPGP_PRIVATE_KEY_FILE="/etc/config/webapp-deployer-api.my.domain.com.pgp.key"
LACONIC_CONFIG="/etc/config/registry.yml"
LRN=lrn://laconic/deployers/webapp-deployer-api.my.domain.com
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
HANDLE_AUCTION_REQUESTS=true
AUCTION_BID_AMOUNT=50000
```
+9
View File
@@ -8,6 +8,15 @@ services:
ports:
- 9555
cerc-webapp-auction-handler:
image: cerc/webapp-deployer-backend:local
restart: always
environment:
RUN_AUCTIONS_HANDLER: "true"
volumes:
- srv:/srv
- config:/etc/config:ro
volumes:
config:
srv:
+2 -1
View File
@@ -36,8 +36,9 @@
"author": "Jakub Synowiec <jsynowiec@users.noreply.github.com>",
"license": "Apache-2.0",
"dependencies": {
"@cerc-io/laconic-sdk": "^0.1.15",
"@cerc-io/registry-sdk": "^0.2.11",
"@openpgp/web-stream-tools": "^0.1.3",
"axios": "^1.6.7",
"body-parser": "^1.20.2",
"express": "^4.18.2",
"express-async-handler": "^1.2.0",
+47
View File
@@ -0,0 +1,47 @@
#!/bin/bash
if [ ! -f "/etc/config/laconic.yml" ]; then
echo "/etc/config/laconic.yml is required."
exit 2
fi
if [ "$HANDLE_AUCTION_REQUESTS" = "true" ]; then
if [ -z "$AUCTION_BID_AMOUNT" ]; then
echo "AUCTION_BID_AMOUNT is required when handling auction requsts."
exit 2
fi
else
echo "Not handling auction requests"
# k8s integration only supports "always" restart policy, so wait indefinitely
# TODO: Exit container once restart policy is supported
tail -f /dev/null
fi
STORAGE_ROOT="${STORAGE_ROOT:-/srv}"
DEPLOYMENTS_DIR="${DEPLOYMENTS_DIR:-$STORAGE_ROOT/deployments}"
REGISTRY_LOCK_FILE="${REGISTRY_LOCK_FILE:-/srv/registry_mutex_lock_file}"
if [[ ! -d "${DEPLOYMENTS_DIR}" ]]; then
mkdir -p "${DEPLOYMENTS_DIR}"
fi
cd /app/
while true; do
echo "=============================================================="
echo "############ DEPLOYMENT AUCTION #############"
laconic-so handle-deployment-auction \
--laconic-config /etc/config/laconic.yml \
--registry-lock-file "${REGISTRY_LOCK_FILE}" \
--state-file "${DEPLOYMENTS_DIR}/autoauction.state" \
--bid-amount ${AUCTION_BID_AMOUNT}
rc=$?
if [ $rc -eq 0 ]; then
echo "############ DEPLOYMENT AUCTION SUCCESS #############"
else
echo "############ DEPLOYMENT AUCTION FAILURE STATUS $rc #############"
fi
sleep ${AUCTION_CHECK_INTERVAL:-10}
done
+22 -1
View File
@@ -1,5 +1,10 @@
#!/bin/bash
if [ "$RUN_AUCTIONS_HANDLER" = "true" ]; then
exec "./scripts/handle-auctions.sh"
exit
fi
function is_privileged {
ip link add dummy0 type dummy >/dev/null
if [[ $? -eq 0 ]]; then
@@ -20,6 +25,11 @@ if [ -z "$DEPLOYMENT_RECORD_NAMESPACE" ]; then
exit 2
fi
if [ "$FQDN_POLICY" = "allow" ] && [ -z "$DEPLOYMENT_IP" ]; then
echo "DEPLOYMENT_IP is required with 'allow' FQDN_POLICY"
exit 2
fi
if [ -z "$IMAGE_REGISTRY" ]; then
echo "IMAGE_REGISTRY is required."
exit 2
@@ -35,10 +45,16 @@ if [ ! -f "/etc/config/kube.yml" ]; then
exit 2
fi
AUCTION_OPTS=""
if [ "$HANDLE_AUCTION_REQUESTS" = "true" ]; then
AUCTION_OPTS="--auction-requests"
fi
STORAGE_ROOT="${STORAGE_ROOT:-/srv}"
DEPLOYMENTS_DIR="${DEPLOYMENTS_DIR:-$STORAGE_ROOT/deployments}"
LOG_DIR="${LOG_DIR:-$STORAGE_ROOT/logs}"
CONTAINERS_DIR="${CONTAINER_DIR:-$STORAGE_ROOT/containers}"
REGISTRY_LOCK_FILE="${REGISTRY_LOCK_FILE:-/srv/registry_mutex_lock_file}"
if [[ ! -d "${DEPLOYMENTS_DIR}" ]]; then
mkdir -p "${DEPLOYMENTS_DIR}"
@@ -116,6 +132,7 @@ while true; do
echo "########### UNDEPLOY ############"
laconic-so undeploy-webapp-from-registry \
--laconic-config /etc/config/laconic.yml \
--registry-lock-file "${REGISTRY_LOCK_FILE}" \
--deployment-parent-dir "${DEPLOYMENTS_DIR}" \
--delete-names \
--delete-volumes \
@@ -123,7 +140,7 @@ while true; do
--include-tags "$INCLUDE_TAGS" \
--exclude-tags "$EXCLUDE_TAGS" \
--lrn "$LRN" \
--min-required-payment ${MIN_REQUIRED_PAYMENT:-0} \
--min-required-payment 0 \
$EXTRA_UNDEPLOY_OPTS \
$UPDATE_OPTS \
--discover
@@ -138,6 +155,7 @@ while true; do
laconic-so deploy-webapp-from-registry \
--kube-config /etc/config/kube.yml \
--laconic-config /etc/config/laconic.yml \
--registry-lock-file "${REGISTRY_LOCK_FILE}" \
--image-registry ${IMAGE_REGISTRY} \
--deployment-parent-dir "${DEPLOYMENTS_DIR}" \
--dns-suffix ${DEPLOYMENT_DNS_SUFFIX} \
@@ -147,11 +165,14 @@ while true; do
--include-tags "$INCLUDE_TAGS" \
--exclude-tags "$EXCLUDE_TAGS" \
--fqdn-policy "${FQDN_POLICY:-prohibit}" \
--ip "${DEPLOYMENT_IP}" \
--lrn "$LRN" \
--min-required-payment ${MIN_REQUIRED_PAYMENT:-0} \
--config-upload-dir "$UPLOAD_DIRECTORY" \
--private-key-file "$OPENPGP_PRIVATE_KEY_FILE" \
--private-key-passphrase "$OPENPGP_PASSPHRASE" \
--recreate-on-deploy \
$AUCTION_OPTS \
$LOG_OPTS \
$EXTRA_DEPLOY_OPTS \
$UPDATE_OPTS \
+178
View 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'}`
};
}
}
+5 -1
View File
@@ -1,7 +1,7 @@
import yaml from 'js-yaml';
import fs from 'fs';
import path from 'path';
import {Registry} from '@cerc-io/laconic-sdk';
import {Registry} from '@cerc-io/registry-sdk';
const loadConfigFile = (configFilePath: string): any => {
const resolvedFilePath = path.resolve(process.cwd(), configFilePath);
@@ -22,6 +22,10 @@ export const Config = {
UPLOAD_MAX_SIZE: process.env.BUILD_LOGS || '1MB',
OPENPGP_PASSPHRASE: process.env.OPENPGP_PASSPHRASE,
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 => {
+41 -8
View File
@@ -1,5 +1,5 @@
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 {createHash} from 'crypto';
@@ -25,10 +25,12 @@ export class RequestStatus {
public url?: string;
public deployment?: string;
public lastUpdate?: string;
public atomPaymentVerified?: boolean;
constructor(public id: string, public createTime: string,) {
this.lastState = 'SUBMITTED';
this.lastUpdate = this.createTime;
this.atomPaymentVerified = false;
}
}
@@ -70,14 +72,18 @@ export class RegHelper {
} else {
const records = await this.registry.getRecordsByIds([id]);
for (const r of records) {
this.cache.set(r.id, r);
if (Array.isArray(r.names)) {
for (const n of r.names) {
this.cache.set(n, r);
// Check if record is not null before accessing its properties
if (r && r.id) {
this.cache.set(r.id, r);
if (Array.isArray(r.names)) {
for (const n of r.names) {
this.cache.set(n, r);
}
}
}
}
return records[0];
// Return the first non-null record, or null if none found
return records.find(r => r !== null) || null;
}
}
@@ -107,12 +113,18 @@ export class RegHelper {
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) {
const requests: any[] = [];
const deployments: any[] = [];
const removalRequests = await this.queryRecords({
type: 'ApplicationDeploymentRemovalRequest',
});
// WebappDeployer records no longer needed for status display
// Payment verification is handled by background processor
if (requestId) {
const request = await this.getRecordById(requestId);
@@ -136,11 +148,13 @@ export class RegHelper {
const deploymentsByRequest = new Map<string, any>();
for (const d of deployments) {
deploymentsByRequest.set(d.attributes.request, d);
if (d && d.attributes?.request) {
deploymentsByRequest.set(d.attributes.request, d);
}
}
const removalsByRequest = new Map<string, any>();
for (const rr of removalRequests) {
if (rr.attributes.request) {
if (rr && rr.attributes?.request) {
removalsByRequest.set(rr.attributes.request, rr);
}
}
@@ -149,9 +163,21 @@ export class RegHelper {
const ret = [];
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);
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);
if (!app) {
status.lastState = 'ERROR';
@@ -160,6 +186,13 @@ export class RegHelper {
status.app = r.attributes.application;
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)) {
const deployment = deploymentsByRequest.get(r.id);
+55 -1
View File
@@ -5,7 +5,9 @@ import {existsSync, readdirSync, readFileSync} from 'fs';
import {Config} from './config.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();
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
app.get('/log/:id', async (req, res) => {
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, () => {
console.log(`listening on ${Config.LISTEN_ADDR}:${Config.LISTEN_PORT}`);
console.log('Payment processor service started');
});
+350
View 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 };