accept atom payments #29
@ -11,9 +11,12 @@ RUN curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | gpg -
|
||||
apt update && apt install -y nodejs
|
||||
|
||||
# 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
|
||||
#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
|
||||
|
||||
# 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/ && \
|
||||
|
40
README.md
40
README.md
@ -1,7 +1,7 @@
|
||||
# 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
|
||||
|
||||
@ -11,6 +11,22 @@ 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
|
||||
|
||||
### Keys
|
||||
@ -44,6 +60,8 @@ 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`.
|
||||
@ -54,7 +72,9 @@ laconic-so publish-deployer-to-registry \
|
||||
--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.
|
||||
@ -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. 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
|
||||
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
|
||||
laconic-so request-webapp-deployment \
|
||||
--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 \
|
||||
--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:
|
||||
@ -135,6 +164,11 @@ 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
|
||||
|
@ -38,6 +38,7 @@
|
||||
"dependencies": {
|
||||
"@cerc-io/laconic-sdk": "^0.1.15",
|
||||
"@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",
|
||||
|
@ -168,6 +168,8 @@ while true; do
|
||||
--ip "${DEPLOYMENT_IP}" \
|
||||
--lrn "$LRN" \
|
||||
--min-required-payment ${MIN_REQUIRED_PAYMENT:-0} \
|
||||
--atom-payment-address "${ATOM_PAYMENT_ADDRESS:-}" \
|
||||
--min-atom-payment ${MIN_ATOM_PAYMENT:-1} \
|
||||
--config-upload-dir "$UPLOAD_DIRECTORY" \
|
||||
--private-key-file "$OPENPGP_PRIVATE_KEY_FILE" \
|
||||
--private-key-passphrase "$OPENPGP_PASSPHRASE" \
|
||||
|
164
src/atomPayments.ts
Normal file
164
src/atomPayments.ts
Normal file
@ -0,0 +1,164 @@
|
||||
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
|
||||
): 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}`
|
||||
};
|
||||
}
|
||||
|
||||
// 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'}`
|
||||
};
|
||||
}
|
||||
}
|
@ -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 => {
|
||||
|
@ -1,8 +1,9 @@
|
||||
import {getRegistry} from './config.js';
|
||||
import {getRegistry, Config} from './config.js';
|
||||
import {Registry} from '@cerc-io/laconic-sdk';
|
||||
|
||||
import stringify from 'json-stable-stringify';
|
||||
import {createHash} from 'crypto';
|
||||
import {verifyAtomPayment} from './atomPayments.js';
|
||||
|
||||
function generateHostnameForApp(app) {
|
||||
const lastPart = app.attributes.name.split('/').pop();
|
||||
@ -25,10 +26,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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -107,12 +110,56 @@ export class RegHelper {
|
||||
return [...records];
|
||||
}
|
||||
|
||||
async verifyAtomPaymentForRequest(request: any, deployer: any) {
|
||||
// Skip if no ATOM payment configuration
|
||||
if (!Config.ATOM_PAYMENT_ADDRESS || !Config.COSMOS_RPC_ENDPOINT) {
|
||||
console.log(`ATOM payment verification skipped: Missing configuration`);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Skip if no payment transaction
|
||||
if (!request.attributes.payment) {
|
||||
console.log(`Request ${request.id}: No payment transaction hash provided`);
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
// Check if deployer has atom payment config
|
||||
const atomPaymentAddress = deployer.attributes.atomPaymentAddress || Config.ATOM_PAYMENT_ADDRESS;
|
||||
const minAtomPayment = deployer.attributes.minimumAtomPayment || Config.MIN_ATOM_PAYMENT;
|
||||
|
||||
if (!atomPaymentAddress) {
|
||||
console.log(`Request ${request.id}: No ATOM payment address configured`);
|
||||
return false;
|
||||
}
|
||||
|
||||
console.log(`Verifying ATOM payment for request ${request.id} - tx: ${request.attributes.payment}`);
|
||||
const verification = await verifyAtomPayment(request.attributes.payment, minAtomPayment);
|
||||
|
||||
if (verification.valid) {
|
||||
console.log(`ATOM payment verified for request ${request.id} - Amount: ${verification.amount}`);
|
||||
return true;
|
||||
} else {
|
||||
console.log(`ATOM payment verification failed for request ${request.id}: ${verification.reason}`);
|
||||
return false;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error verifying ATOM payment for request ${request.id}:`, error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async deploymentRequestStatus(requestId?: string) {
|
||||
const requests: any[] = [];
|
||||
const deployments: any[] = [];
|
||||
const removalRequests = await this.queryRecords({
|
||||
type: 'ApplicationDeploymentRemovalRequest',
|
||||
});
|
||||
|
||||
// Get all WebappDeployer records to check for atom payment configuration
|
||||
const deployers = await this.queryRecords({
|
||||
type: 'WebappDeployer',
|
||||
});
|
||||
|
||||
if (requestId) {
|
||||
const request = await this.getRecordById(requestId);
|
||||
@ -133,6 +180,16 @@ export class RegHelper {
|
||||
|
||||
requests.sort((a, b) => a.createTime === b.createTime ? 0 : a.createTime > b.createTime ? 1 : -1,);
|
||||
requests.reverse();
|
||||
|
||||
// Create a map of deployers by their LRN for quick lookup
|
||||
const deployersByLRN = new Map<string, any>();
|
||||
for (const deployer of deployers) {
|
||||
if (deployer.names && deployer.names.length > 0) {
|
||||
for (const name of deployer.names) {
|
||||
deployersByLRN.set(name, deployer);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const deploymentsByRequest = new Map<string, any>();
|
||||
for (const d of deployments) {
|
||||
@ -160,6 +217,26 @@ export class RegHelper {
|
||||
|
||||
status.app = r.attributes.application;
|
||||
const hostname = r.attributes.dns ?? generateHostnameForApp(app);
|
||||
|
||||
// Mark as ATOM payment verified if applicable
|
||||
if (r.attributes.payment && !deploymentsByRequest.has(r.id) && !removalsByRequest.has(r.id)) {
|
||||
// Find the deployer record for this request
|
||||
const deployerLRN = r.attributes.deployer;
|
||||
if (deployerLRN && deployersByLRN.has(deployerLRN)) {
|
||||
const deployer = deployersByLRN.get(deployerLRN);
|
||||
|
||||
// Check if this is a valid ATOM payment
|
||||
if (deployer.attributes.atomPaymentAddress || Config.ATOM_PAYMENT_ADDRESS) {
|
||||
// Just verify but don't mark as used since this is just for status display
|
||||
const isValidAtomPayment = await this.verifyAtomPaymentForRequest(r, deployer);
|
||||
|
||||
if (isValidAtomPayment) {
|
||||
// If the payment is valid, mark it in the status
|
||||
status.atomPaymentVerified = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (deploymentsByRequest.has(r.id)) {
|
||||
const deployment = deploymentsByRequest.get(r.id);
|
||||
|
42
src/main.ts
42
src/main.ts
@ -5,7 +5,8 @@ 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';
|
||||
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
@ -112,6 +113,45 @@ app.post('/upload/config', configUploadParser, async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// API endpoint to verify ATOM payments
|
||||
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;
|
||||
const result = await verifyAtomPayment(txHash, requiredAmount, Boolean(markAsUsed));
|
||||
|
||||
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 {
|
||||
|
Loading…
Reference in New Issue
Block a user