accept atom payments #29

Open
zramsay wants to merge 3 commits from zach/atom-payments into main
8 changed files with 333 additions and 8 deletions

View File

@ -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/ && \

View File

@ -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

View File

@ -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",

View File

@ -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
View 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'}`
};
}
}

View File

@ -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 => {

View File

@ -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);

View File

@ -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 {