Compare commits
18 Commits
main
...
sk-db-path
Author | SHA1 | Date | |
---|---|---|---|
|
911240877f | ||
d87a6729c5 | |||
3dd1ff74ab | |||
d3e6f7ad12 | |||
7c95548389 | |||
bdc9987b57 | |||
7300d0133d | |||
b211e88441 | |||
fa168e5c61 | |||
7bdc0a241d | |||
ff48bdb954 | |||
36f298ff96 | |||
fa6a88f297 | |||
1582f8887d | |||
6e6c10edc1 | |||
60728920f3 | |||
5277b9d4ca | |||
76eb0a994c |
30
.env.example
Normal file
30
.env.example
Normal file
@ -0,0 +1,30 @@
|
||||
# Get key from https://fal.ai
|
||||
FAL_AI_KEY=
|
||||
|
||||
NEXT_PUBLIC_MTM_MINT_ADDRESS=97RggLo3zV5kFGYW4yoQTxr4Xkz4Vg2WPHzNYXXWpump
|
||||
NEXT_PUBLIC_MTM_RECIPIENT_MULTISIG_ADDRESS=FFDx3SdAEeXrp6BTmStB4BDHpctGsaasZq4FFcowRobY
|
||||
NEXT_PUBLIC_SOLANA_RPC_URL=https://skilled-prettiest-seed.solana-mainnet.quiknode.pro/eeecfebd04e345f69f1900cc3483cbbfea02a158
|
||||
NEXT_PUBLIC_SOLANA_WEBSOCKET_URL=wss://skilled-prettiest-seed.solana-mainnet.quiknode.pro/eeecfebd04e345f69f1900cc3483cbbfea02a158
|
||||
NEXT_PUBLIC_USDC_MINT_ADDRESS=EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v
|
||||
|
||||
# Generate a key at https://app.pinata.cloud/developers/api-keys
|
||||
PINATA_JWT=
|
||||
|
||||
# Get the gateway from https://app.pinata.cloud/gateway
|
||||
PINATA_GATEWAY=
|
||||
|
||||
# Change to your website URL
|
||||
# For development: SITE_URL=http://localhost:3000
|
||||
SITE_URL=https://memes.markto.market
|
||||
VERIFY_TWEET_SECRET=
|
||||
NEXT_PUBLIC_TWITTER_HANDLE=
|
||||
|
||||
WSOL_LOCKER_ACCOUNT_PK=
|
||||
WSOL_MINT_ADDRESS=So11111111111111111111111111111111111111112
|
||||
|
||||
# Duration in seconds that WSOL will be locked for
|
||||
WSOL_LOCK_DURATION_IN_SECONDS=172800 # 48 hours
|
||||
REWARD_MULTIPLIER=4
|
||||
MAX_RETRIES_FOR_LOCK_TX=5
|
||||
|
||||
DB_PATH='./database.sqlite'
|
11
.gitignore
vendored
Normal file
11
.gitignore
vendored
Normal file
@ -0,0 +1,11 @@
|
||||
node_modules
|
||||
.next
|
||||
|
||||
.env
|
||||
.env.local
|
||||
.env.development
|
||||
.env.production
|
||||
.env.test
|
||||
|
||||
database.sqlite
|
||||
dist
|
2
Anchor.toml
Normal file
2
Anchor.toml
Normal file
@ -0,0 +1,2 @@
|
||||
[provider]
|
||||
cluster = "mainnet"
|
79
README.md
Normal file
79
README.md
Normal file
@ -0,0 +1,79 @@
|
||||
# sol-mem-gen
|
||||
|
||||
This project is a Solana-based meme generator that allows users to connect their wallets, pay with MTM tokens, and generate memes using AI models.
|
||||
|
||||
## Features
|
||||
|
||||
- Connect to Solflare and Phantom wallets
|
||||
- Pay with MTM tokens
|
||||
- Generate memes using various AI models
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Node.js
|
||||
- npm
|
||||
- Solflare or Phantom wallet
|
||||
|
||||
## Setup
|
||||
|
||||
- Install dependencies:
|
||||
|
||||
```sh
|
||||
npm install
|
||||
```
|
||||
|
||||
- Copy the `.env.example` file to `.env` and add your API keys:
|
||||
|
||||
```sh
|
||||
cp .env.example .env.local
|
||||
```
|
||||
|
||||
- Add your FAL AI key to the `.env` file:
|
||||
|
||||
```env
|
||||
# Get key from https://fal.ai/
|
||||
FAL_AI_KEY=your_fal_ai_key
|
||||
```
|
||||
|
||||
- Setup a project on <https://app.pinata.cloud>
|
||||
|
||||
- Visit <https://app.pinata.cloud/developers/api-keys> and click the `New Key` button to create a new key
|
||||
|
||||
- Choose the `Admin` scope and create the API key
|
||||
|
||||
- Copy the JWT that is displayed
|
||||
|
||||
- Add the following to `.env`:
|
||||
|
||||
```env
|
||||
PINATA_JWT=
|
||||
|
||||
# Get the gateway from https://app.pinata.cloud/gateway
|
||||
PINATA_GATEWAY=
|
||||
|
||||
# Mark to market twitter handle: @mark_2_market0
|
||||
NEXT_PUBLIC_TWITTER_HANDLE=
|
||||
|
||||
# Add a secret random string that will be used to access the page to submit tweets for verification
|
||||
# This secret will be checked against the URL to either allow or deny access
|
||||
# The URL should look something like this: http://localhost:3000/submit-tweets/<your-verify-tweet-secret>
|
||||
VERIFY_TWEET_SECRET=
|
||||
```
|
||||
|
||||
- Run the development server:
|
||||
|
||||
```sh
|
||||
npm run dev
|
||||
```
|
||||
|
||||
- Open your browser and navigate to `http://localhost:3000`.
|
||||
|
||||
## Usage
|
||||
|
||||
- Connect your Solflare or Phantom wallet using the "Connect Wallet" button.
|
||||
- Select an AI model and enter a prompt to generate a meme.
|
||||
- Pay the required MTM tokens and wait for the meme to be generated.
|
||||
|
||||
## Deploy
|
||||
|
||||
Follow [instructions](./deploy/README.md) for deploying `sol-mem-gen` and `sol-token-locker`
|
10
deploy/.registry.env.example
Normal file
10
deploy/.registry.env.example
Normal file
@ -0,0 +1,10 @@
|
||||
# ENV for registry operations
|
||||
|
||||
# Bond to use
|
||||
REGISTRY_BOND_ID=
|
||||
|
||||
# Target deployer LRN
|
||||
DEPLOYER_LRN=
|
||||
|
||||
# Authority to deploy the app under
|
||||
AUTHORITY=
|
40
deploy/Dockerfile
Normal file
40
deploy/Dockerfile
Normal file
@ -0,0 +1,40 @@
|
||||
ARG VARIANT=20-bullseye
|
||||
FROM node:${VARIANT}
|
||||
|
||||
ARG USERNAME=node
|
||||
ARG NPM_GLOBAL=/usr/local/share/npm-global
|
||||
|
||||
# Add NPM global to PATH.
|
||||
ENV PATH=${NPM_GLOBAL}/bin:${PATH}
|
||||
|
||||
RUN \
|
||||
# Configure global npm install location, use group to adapt to UID/GID changes
|
||||
if ! cat /etc/group | grep -e "^npm:" > /dev/null 2>&1; then groupadd -r npm; fi \
|
||||
&& usermod -a -G npm ${USERNAME} \
|
||||
&& umask 0002 \
|
||||
&& mkdir -p ${NPM_GLOBAL} \
|
||||
&& touch /usr/local/etc/npmrc \
|
||||
&& chown ${USERNAME}:npm ${NPM_GLOBAL} /usr/local/etc/npmrc \
|
||||
&& chmod g+s ${NPM_GLOBAL} \
|
||||
&& npm config -g set prefix ${NPM_GLOBAL} \
|
||||
&& su ${USERNAME} -c "npm config -g set prefix ${NPM_GLOBAL}" \
|
||||
# Install eslint
|
||||
&& su ${USERNAME} -c "umask 0002 && npm install -g eslint" \
|
||||
&& npm cache clean --force > /dev/null 2>&1
|
||||
|
||||
RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \
|
||||
&& apt-get -y install --no-install-recommends jq bash
|
||||
|
||||
# laconic-so
|
||||
RUN curl -LO https://git.vdb.to/cerc-io/stack-orchestrator/releases/download/latest/laconic-so && \
|
||||
chmod +x ./laconic-so && \
|
||||
mv ./laconic-so /usr/bin/laconic-so
|
||||
|
||||
# Configure the npm registry
|
||||
RUN npm config set @cerc-io:registry https://git.vdb.to/api/packages/cerc-io/npm/
|
||||
|
||||
# DEBUG, remove
|
||||
RUN yarn info @cerc-io/laconic-registry-cli
|
||||
|
||||
# Globally install the cli package
|
||||
RUN yarn global add @cerc-io/laconic-registry-cli
|
121
deploy/README.md
Normal file
121
deploy/README.md
Normal file
@ -0,0 +1,121 @@
|
||||
# Deploy
|
||||
|
||||
## Setup
|
||||
|
||||
### sol-mem-gen
|
||||
|
||||
* Clone the repo:
|
||||
|
||||
```bash
|
||||
git clone git@git.vdb.to:deep-stack/sol-mem-gen.git
|
||||
cd sol-mem-gen/deploy
|
||||
```
|
||||
|
||||
* Build registry CLI image:
|
||||
|
||||
```bash
|
||||
docker build -t cerc/laconic-registry-cli .
|
||||
|
||||
# Builds image cerc/laconic-registry-cli:latest
|
||||
```
|
||||
|
||||
* Configure `userKey` and `bondId` in the registry CLI config:
|
||||
|
||||
```bash
|
||||
nano config.yml
|
||||
```
|
||||
|
||||
* Add configuration for registry operations:
|
||||
|
||||
```bash
|
||||
cp .registry.env.example .registry.env
|
||||
|
||||
# Fill in the required values
|
||||
nano .registry.env
|
||||
```
|
||||
|
||||
* Add configuration for the app:
|
||||
|
||||
```bash
|
||||
curl -s https://git.vdb.to/deep-stack/sol-mem-gen/raw/branch/main/.env.example -o .app.env
|
||||
|
||||
# Fill in the required values
|
||||
nano .app.env
|
||||
```
|
||||
|
||||
### sol-token-locker
|
||||
|
||||
* Create a dir for `sol-token-locker` deployment:
|
||||
|
||||
```bash
|
||||
# In deployments dir
|
||||
mkdir sol-token-locker
|
||||
cd sol-token-locker
|
||||
```
|
||||
|
||||
* Follow [instructions](https://git.vdb.to/deep-stack/sol-token-locker/src/branch/main/stack-orchestrator/stacks/sol-token-locker/README.md) to setup the `sol-token-locker` stack in `sol-token-locker` directory
|
||||
|
||||
## Run
|
||||
|
||||
### sol-mem-gen
|
||||
|
||||
* Update configuration for the app as required:
|
||||
|
||||
```bash
|
||||
# In sol-mem-gen/deploy dir
|
||||
nano sol-mem-gen/.app.env
|
||||
```
|
||||
|
||||
* Deploy `sol-mem-gen` App:
|
||||
|
||||
```bash
|
||||
# In sol-mem-gen/deploy dir
|
||||
docker run -it \
|
||||
-v ./:/app/deploy -w /app/deploy \
|
||||
-e DEPLOYMENT_DNS=memes.markto.market \
|
||||
cerc/laconic-registry-cli:latest \
|
||||
./deploy.sh
|
||||
```
|
||||
|
||||
* Check deployment logs on deployer UI: <https://webapp-deployer-ui.apps.vaasl.io/>
|
||||
|
||||
* Visit deployed app: <https://memes.markto.market>
|
||||
|
||||
* To redeploy the app (using <https://git.vdb.to/deep-stack/sol-mem-gen> `main` branch), repeat the previous steps
|
||||
|
||||
### sol-token-locker
|
||||
|
||||
* Update configuration for token locker as required:
|
||||
|
||||
```bash
|
||||
# In deployments dir
|
||||
cd sol-token-locker
|
||||
nano sol-token-locker-deployment/config.env
|
||||
```
|
||||
|
||||
* [Config reference](https://git.vdb.to/deep-stack/sol-token-locker/src/branch/main/stack-orchestrator/stacks/sol-token-locker/README.md#configuration)
|
||||
|
||||
* Start the deployment:
|
||||
|
||||
```bash
|
||||
laconic-so deployment --dir sol-token-locker-deployment start
|
||||
```
|
||||
|
||||
* Follow logs:
|
||||
|
||||
```bash
|
||||
laconic-so deployment --dir sol-token-locker-deployment logs sol-token-locker -f
|
||||
```
|
||||
|
||||
* For updating the deployment:
|
||||
|
||||
```bash
|
||||
# Stop the deployment
|
||||
laconic-so deployment --dir sol-token-locker-deployment stop
|
||||
|
||||
# Update the config as required
|
||||
nano sol-token-locker-deployment/config.env
|
||||
|
||||
# Re-start deployment
|
||||
laconic-so deployment --dir sol-token-locker-deployment start
|
||||
```
|
10
deploy/config.yml
Normal file
10
deploy/config.yml
Normal file
@ -0,0 +1,10 @@
|
||||
# Registry CLI config
|
||||
services:
|
||||
registry:
|
||||
rpcEndpoint: 'https://laconicd-sapo.laconic.com'
|
||||
gqlEndpoint: 'https://laconicd-sapo.laconic.com/api'
|
||||
userKey:
|
||||
bondId:
|
||||
chainId: laconic-testnet-2
|
||||
fees: 2.5
|
||||
gasPrice: 0.01alnt
|
126
deploy/deploy.sh
Executable file
126
deploy/deploy.sh
Executable file
@ -0,0 +1,126 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Fail on error
|
||||
set -e
|
||||
|
||||
source .registry.env
|
||||
echo "Using REGISTRY_BOND_ID: $REGISTRY_BOND_ID"
|
||||
echo "Using DEPLOYER_LRN: $DEPLOYER_LRN"
|
||||
echo "Using AUTHORITY: $AUTHORITY"
|
||||
|
||||
# Repository URL
|
||||
REPO_URL="https://git.vdb.to/deep-stack/sol-mem-gen"
|
||||
|
||||
# Get the latest commit hash from the repository
|
||||
LATEST_HASH=$(git ls-remote $REPO_URL HEAD | awk '{print $1}')
|
||||
|
||||
PACKAGE_VERSION=$(curl -s $REPO_URL/raw/branch/main/package.json | jq -r .version)
|
||||
APP_NAME=sol-mem-gen
|
||||
|
||||
echo "Repo: ${REPO_URL}"
|
||||
echo "Latest hash: ${LATEST_HASH}"
|
||||
echo "App version: ${PACKAGE_VERSION}"
|
||||
echo "Deployment DNS: ${DEPLOYMENT_DNS}"
|
||||
|
||||
# Current date and time for note
|
||||
CURRENT_DATE_TIME=$(date -u)
|
||||
|
||||
CONFIG_FILE=config.yml
|
||||
|
||||
# Reference: https://git.vdb.to/cerc-io/test-progressive-web-app/src/branch/main/scripts
|
||||
|
||||
# Get latest version from registry and increment application-record version
|
||||
NEW_APPLICATION_VERSION=$(laconic -c $CONFIG_FILE registry record list --type ApplicationRecord --all --name "$APP_NAME" 2>/dev/null | jq -r -s ".[] | sort_by(.createTime) | reverse | [ .[] | select(.bondId == \"$REGISTRY_BOND_ID\") ] | .[0].attributes.version" | awk -F. -v OFS=. '{$NF += 1 ; print}')
|
||||
|
||||
if [ -z "$NEW_APPLICATION_VERSION" ] || [ "1" == "$NEW_APPLICATION_VERSION" ]; then
|
||||
# Set application-record version if no previous records were found
|
||||
NEW_APPLICATION_VERSION=0.0.1
|
||||
fi
|
||||
|
||||
# Generate application-record.yml with incremented version
|
||||
mkdir -p records
|
||||
RECORD_FILE=./records/application-record.yml
|
||||
|
||||
cat >$RECORD_FILE <<EOF
|
||||
record:
|
||||
type: ApplicationRecord
|
||||
version: $NEW_APPLICATION_VERSION
|
||||
repository_ref: $LATEST_HASH
|
||||
repository: ["$REPO_URL"]
|
||||
app_type: webapp
|
||||
name: $APP_NAME
|
||||
app_version: $PACKAGE_VERSION
|
||||
EOF
|
||||
|
||||
echo "Application record generated successfully: $RECORD_FILE"
|
||||
|
||||
# Publish ApplicationRecord
|
||||
publish_response=$(laconic -c $CONFIG_FILE registry record publish --filename $RECORD_FILE)
|
||||
rc=$?
|
||||
if [ $rc -ne 0 ]; then
|
||||
echo "FATAL: Failed to publish record"
|
||||
exit $rc
|
||||
fi
|
||||
RECORD_ID=$(echo $publish_response | jq -r '.id')
|
||||
echo "ApplicationRecord published, setting names next"
|
||||
echo $RECORD_ID
|
||||
|
||||
# Set name to record
|
||||
REGISTRY_APP_LRN="lrn://$AUTHORITY/applications/$APP_NAME"
|
||||
|
||||
name1="$REGISTRY_APP_LRN@${PACKAGE_VERSION}"
|
||||
sleep 2
|
||||
laconic -c $CONFIG_FILE registry name set "$name1" "$RECORD_ID"
|
||||
rc=$?
|
||||
if [ $rc -ne 0 ]; then
|
||||
echo "FATAL: Failed to set name: $REGISTRY_APP_LRN@${PACKAGE_VERSION}"
|
||||
exit $rc
|
||||
fi
|
||||
echo "$name1 set for ApplicationRecord"
|
||||
|
||||
name2="$REGISTRY_APP_LRN@${LATEST_HASH}"
|
||||
sleep 2
|
||||
laconic -c $CONFIG_FILE registry name set "$name2" "$RECORD_ID"
|
||||
rc=$?
|
||||
if [ $rc -ne 0 ]; then
|
||||
echo "FATAL: Failed to set hash"
|
||||
exit $rc
|
||||
fi
|
||||
echo "$name2 set for ApplicationRecord"
|
||||
|
||||
name3="$REGISTRY_APP_LRN"
|
||||
sleep 2
|
||||
# Set name if latest release
|
||||
laconic -c $CONFIG_FILE registry name set "$name3" "$RECORD_ID"
|
||||
rc=$?
|
||||
if [ $rc -ne 0 ]; then
|
||||
echo "FATAL: Failed to set release"
|
||||
exit $rc
|
||||
fi
|
||||
echo "$name3 set for ApplicationRecord"
|
||||
|
||||
# Check if record found for REGISTRY_APP_LRN
|
||||
query_response=$(laconic -c $CONFIG_FILE registry name resolve "$REGISTRY_APP_LRN")
|
||||
rc=$?
|
||||
if [ $rc -ne 0 ]; then
|
||||
echo "FATAL: Failed to query name"
|
||||
exit $rc
|
||||
fi
|
||||
APP_RECORD=$(echo $query_response | jq '.[0]')
|
||||
if [ -z "$APP_RECORD" ] || [ "null" == "$APP_RECORD" ]; then
|
||||
echo "No record found for $REGISTRY_APP_LRN."
|
||||
exit 1
|
||||
fi
|
||||
echo "Name resolution successful"
|
||||
|
||||
sleep 2
|
||||
echo "Requesting a webapp deployment for $name2, using deployer $DEPLOYER_LRN"
|
||||
laconic-so request-webapp-deployment \
|
||||
--laconic-config $CONFIG_FILE \
|
||||
--deployer $DEPLOYER_LRN \
|
||||
--app $name2 \
|
||||
--env-file ./.app.env \
|
||||
--dns $DEPLOYMENT_DNS \
|
||||
--make-payment auto
|
||||
|
||||
echo "Done"
|
8
index.html
Normal file
8
index.html
Normal file
@ -0,0 +1,8 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
</head>
|
||||
<body>
|
||||
</body>
|
||||
</html>
|
3388
package-lock.json
generated
3388
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
19
package.json
19
package.json
@ -3,22 +3,32 @@
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"dev": "ts-node --project tsconfig.server.json server.ts",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"start": "tsc --project tsconfig.server.json && NODE_ENV=production PORT=80 node dist/server.js",
|
||||
"lint": "next lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@coral-xyz/anchor": "^0.30.1",
|
||||
"@fal-ai/client": "^1.2.1",
|
||||
"@google/generative-ai": "^0.21.0",
|
||||
"@solana/spl-token": "^0.3.8",
|
||||
"@solana/spl-token": "^0.3.11",
|
||||
"@solana/web3.js": "^1.78.4",
|
||||
"big.js": "^6.2.2",
|
||||
"bn.js": "^5.2.0",
|
||||
"dotenv": "^16.4.7",
|
||||
"next": "13.5.4",
|
||||
"openai": "^4.77.0",
|
||||
"pinata-web3": "^0.5.4",
|
||||
"react": "^18",
|
||||
"react-dom": "^18"
|
||||
"react-dom": "^18",
|
||||
"sqlite3": "^5.1.7",
|
||||
"typeorm": "^0.3.12"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/big.js": "^6.2.2",
|
||||
"@types/bn.js": "^5.1.6",
|
||||
"@types/bs58": "^4.0.4",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^18",
|
||||
"@types/react-dom": "^18",
|
||||
@ -27,6 +37,7 @@
|
||||
"eslint-config-next": "13.5.4",
|
||||
"postcss": "^8",
|
||||
"tailwindcss": "^3",
|
||||
"ts-node": "^10.9.2",
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
|
45
quotes-service.ts
Normal file
45
quotes-service.ts
Normal file
@ -0,0 +1,45 @@
|
||||
import assert from "assert";
|
||||
import BN from "bn.js";
|
||||
import fetch from 'node-fetch';
|
||||
import Big from 'big.js';
|
||||
|
||||
assert(process.env.NEXT_PUBLIC_USDC_MINT_ADDRESS, 'USDC_MINT_ADDRESS is required');
|
||||
assert(process.env.NEXT_PUBLIC_MTM_MINT_ADDRESS, 'MTM_MINT_ADDRESS is required');
|
||||
|
||||
const MTM_TOKEN_MINT = process.env.NEXT_PUBLIC_MTM_MINT_ADDRESS;
|
||||
const USDC_MINT = process.env.NEXT_PUBLIC_USDC_MINT_ADDRESS;
|
||||
|
||||
class QuotesService {
|
||||
// Stores the MTM amount for 1 USDC
|
||||
private cachedMTMAmounts: BN[] = [];
|
||||
|
||||
async fetchAndCacheQuotes(): Promise<void> {
|
||||
try {
|
||||
const url = `https://api.jup.ag/price/v2?ids=${USDC_MINT}&vsToken=${MTM_TOKEN_MINT}`;
|
||||
const response = await fetch(url);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch quote: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const quoteResponse = await response.json();
|
||||
|
||||
// Handle price with big.js, then convert to bn.js instance
|
||||
const priceMTMFor1USDC = new Big(quoteResponse['data'][USDC_MINT]['price']).toFixed(6);
|
||||
const priceMTMFor1USDCInBaseUnits = new BN(new Big(priceMTMFor1USDC).times(new Big(10).pow(6)).toString());
|
||||
|
||||
this.cachedMTMAmounts.push(priceMTMFor1USDCInBaseUnits);
|
||||
if (this.cachedMTMAmounts.length > 3) {
|
||||
this.cachedMTMAmounts.shift();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching quotes:', error);
|
||||
}
|
||||
}
|
||||
|
||||
getMTMAmountsFor1USDC(): BN[] {
|
||||
return this.cachedMTMAmounts;
|
||||
}
|
||||
}
|
||||
|
||||
export { QuotesService };
|
56
server.ts
Normal file
56
server.ts
Normal file
@ -0,0 +1,56 @@
|
||||
import { createServer } from 'http';
|
||||
import { parse } from 'url';
|
||||
import next from 'next';
|
||||
import { DataSource, EntityTarget } from 'typeorm';
|
||||
|
||||
// Reference: https://github.com/motdotla/dotenv?tab=readme-ov-file#how-do-i-use-dotenv-with-import
|
||||
import 'dotenv/config'
|
||||
|
||||
import { QuotesService } from './quotes-service';
|
||||
import { initializeDataSource } from './src/data-source';
|
||||
import { Payment } from './src/entity/Payment';
|
||||
import { Tweet } from './src/entity/Tweet';
|
||||
|
||||
const port = parseInt(process.env.PORT || '3000', 10);
|
||||
const app = next({ dev: process.env.NODE_ENV !== 'production' });
|
||||
const handle = app.getRequestHandler();
|
||||
|
||||
const quotesService = new QuotesService();
|
||||
|
||||
declare global {
|
||||
namespace NodeJS {
|
||||
interface Global {
|
||||
quotesService: QuotesService
|
||||
appDataSource: DataSource
|
||||
entities: { [key: string]: EntityTarget<any>}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: Look for a better way to use quotesService
|
||||
// Initialize global quotes service
|
||||
global.quotesService = quotesService
|
||||
|
||||
global.entities = {
|
||||
Payment,
|
||||
Tweet
|
||||
};
|
||||
|
||||
app.prepare().then(async() => {
|
||||
global.appDataSource = await initializeDataSource();
|
||||
|
||||
const server = createServer(async (req, res) => {
|
||||
const parsedUrl = parse(req.url!, true);
|
||||
|
||||
handle(req, res, parsedUrl);
|
||||
});
|
||||
|
||||
await quotesService.fetchAndCacheQuotes(); // Initial store
|
||||
|
||||
server.listen(port, () => {
|
||||
console.log(`> Server listening at http://localhost:${port}`);
|
||||
});
|
||||
|
||||
// Interval setup
|
||||
setInterval(async () => await quotesService.fetchAndCacheQuotes(), 5 * 60 * 1000); // Update cache every 5 minutes
|
||||
});
|
@ -1,5 +1,10 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import BN from 'bn.js';
|
||||
|
||||
import { fal } from "@fal-ai/client"
|
||||
import { FLUX_MODELS } from '../../../services/fluxService'
|
||||
import { verifyPayment, markSignatureAsUsed } from '../../../utils/verifyPayment';
|
||||
import { uploadToPinata } from '../../../utils/uploadToPinata';
|
||||
|
||||
if (!process.env.FAL_AI_KEY) {
|
||||
throw new Error('FAL_AI_KEY is not configured in environment variables')
|
||||
@ -16,7 +21,9 @@ const IMAGE_HEIGHT: number = 1024
|
||||
|
||||
export async function POST(req: NextRequest): Promise<NextResponse> {
|
||||
try {
|
||||
const { prompt, modelId } = await req.json()
|
||||
const { prompt, modelId, transactionSignature } = await req.json();
|
||||
const host = req.headers.get("host"); // Get the hostname from request headers
|
||||
const protocol = req.headers.get("x-forwarded-proto") || "http"; // Handle reverse proxies
|
||||
|
||||
if (!prompt || !modelId) {
|
||||
return NextResponse.json(
|
||||
@ -25,6 +32,39 @@ export async function POST(req: NextRequest): Promise<NextResponse> {
|
||||
)
|
||||
}
|
||||
|
||||
if (!transactionSignature) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Transaction signature is required' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const model = FLUX_MODELS.find((model) => model.modelId === modelId)
|
||||
if (!model) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid modelId' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const amountOfMTM: BN[] = (global as any).quotesService.getMTMAmountsFor1USDC();
|
||||
const lowestAmountOfMTM = amountOfMTM.reduce((minQuote, currentQuote) => BN.min(minQuote, currentQuote), amountOfMTM[0]);
|
||||
|
||||
const scale = new BN(100);
|
||||
const scaledModelCost = new BN(model.cost).mul(scale);
|
||||
|
||||
const lowestTokenAmountForModel = scaledModelCost.mul(new BN(lowestAmountOfMTM)).div(scale);
|
||||
const isPaymentVerified = await verifyPayment(transactionSignature, lowestTokenAmountForModel);
|
||||
|
||||
if (!isPaymentVerified) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Payment verification failed or transaction signature has already been used' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
await markSignatureAsUsed(transactionSignature);
|
||||
|
||||
console.log('Generating with Flux model:', modelId)
|
||||
console.log('Prompt:', prompt)
|
||||
|
||||
@ -51,14 +91,31 @@ export async function POST(req: NextRequest): Promise<NextResponse> {
|
||||
|
||||
if (!imageUrl) {
|
||||
console.error('No image URL in response:', result)
|
||||
throw new Error('No image URL in response')
|
||||
return NextResponse.json(
|
||||
{ error: 'No image URL in response: ', result },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json({ imageUrl })
|
||||
const pinataResult = await uploadToPinata(imageUrl, transactionSignature);
|
||||
|
||||
if (pinataResult.error) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to upload image to Pinata' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
// Extract CID from the URL
|
||||
const cid = pinataResult.imageUrl!.split("/ipfs/")[1];
|
||||
|
||||
const publicUrl = `${protocol}://${host}/api/image/${cid}`;
|
||||
|
||||
return NextResponse.json({ imageUrl: publicUrl })
|
||||
} catch (error) {
|
||||
console.error('Flux generation error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : 'Failed to generate image' },
|
||||
{ error: 'Failed to generate image' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
30
src/app/api/image/[cid]/route.ts
Normal file
30
src/app/api/image/[cid]/route.ts
Normal file
@ -0,0 +1,30 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
export async function GET(req: NextRequest, { params }: { params: { cid?: string } }) {
|
||||
const { cid } = params;
|
||||
|
||||
if (!cid) {
|
||||
return NextResponse.json({ error: 'CID is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
const pinataUrl = `https://${process.env.PINATA_GATEWAY}/ipfs/${cid}`;
|
||||
|
||||
try {
|
||||
const response = await fetch(pinataUrl);
|
||||
|
||||
if (!response.ok) {
|
||||
return NextResponse.json({ error: 'Failed to fetch from Pinata' }, { status: response.status });
|
||||
}
|
||||
|
||||
const contentType = response.headers.get('content-type') || 'application/octet-stream';
|
||||
const buffer = await response.arrayBuffer();
|
||||
|
||||
return new NextResponse(buffer, {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': contentType },
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching from Pinata:', error);
|
||||
return NextResponse.json({ error: 'Server error' }, { status: 500 });
|
||||
}
|
||||
}
|
23
src/app/api/lock/route.ts
Normal file
23
src/app/api/lock/route.ts
Normal file
@ -0,0 +1,23 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
import { createRewardLock } from "../../../utils/create-lock";
|
||||
import { extractTxInfo } from "../../../utils/extractTxInfo";
|
||||
|
||||
// export async function GET(req: NextRequest) {
|
||||
// try {
|
||||
// const { searchParams } = new URL(req.url);
|
||||
// const signature = searchParams.get('signature')
|
||||
|
||||
// const { authority, amount } = await extractTxInfo(signature);
|
||||
// if (!authority || Number(amount) <= 0) {
|
||||
// return NextResponse.json({ error: "Invalid transaction details" }, { status: 400 });
|
||||
// }
|
||||
|
||||
// const escrow = await createRewardLock(authority, amount);
|
||||
// return NextResponse.json({ success: true, data: { escrow } });
|
||||
|
||||
// } catch (error) {
|
||||
// console.error('API route error:', error);
|
||||
// return NextResponse.json({ error: "Internal server error" }, { status: 500 });
|
||||
// }
|
||||
// }
|
12
src/app/api/quotes/route.ts
Normal file
12
src/app/api/quotes/route.ts
Normal file
@ -0,0 +1,12 @@
|
||||
import BN from 'bn.js';
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
try {
|
||||
const amountOfMTM: BN[] = (global as any).quotesService.getMTMAmountsFor1USDC();
|
||||
const latestMTMAmount = amountOfMTM[amountOfMTM.length - 1].toString();
|
||||
return NextResponse.json({ latestMTMAmount });
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: 'Failed to fetch quotes' }, { status: 500 });
|
||||
}
|
||||
}
|
51
src/app/api/tweet/route.ts
Normal file
51
src/app/api/tweet/route.ts
Normal file
@ -0,0 +1,51 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import { processTweet, verifySignatureInTweet } from '../../../utils/verifyTweet';
|
||||
import { extractData } from '../../../utils/tweetMessage';
|
||||
|
||||
export async function POST(req: NextRequest): Promise<NextResponse> {
|
||||
try {
|
||||
const { tweetUrl, secret } = await req.json();
|
||||
|
||||
if(secret !== process.env.VERIFY_TWEET_SECRET) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Access denied' },
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
const url = `https://publish.twitter.com/oembed?url=${tweetUrl}&maxwidth=600`;
|
||||
|
||||
const response = await fetch(url);
|
||||
const data = await response.json();
|
||||
|
||||
const { handle, txSignature, memeUrl } = extractData(data.html);
|
||||
|
||||
if (!handle || !txSignature) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Could not extract tweet data' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
const isSigVerified = await verifySignatureInTweet(txSignature);
|
||||
const isHandleCorrect = handle === process.env.NEXT_PUBLIC_TWITTER_HANDLE;
|
||||
|
||||
// TODO: Verify dynamic page URL in tweet
|
||||
const isVerified = isSigVerified && isHandleCorrect;
|
||||
if (!isVerified) {
|
||||
throw new Error('Tweet is not valid');
|
||||
}
|
||||
|
||||
// Verify and store valid tweet
|
||||
const result = await processTweet(txSignature, memeUrl);
|
||||
|
||||
return NextResponse.json(result)
|
||||
} catch (error) {
|
||||
console.error('Error while verifying tweet:', error)
|
||||
return NextResponse.json(
|
||||
{ error: error.message },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
50
src/app/memes/[id]/page.tsx
Normal file
50
src/app/memes/[id]/page.tsx
Normal file
@ -0,0 +1,50 @@
|
||||
import type { Metadata, ResolvingMetadata } from 'next';
|
||||
|
||||
interface Props {
|
||||
params: { id: string };
|
||||
}
|
||||
|
||||
function getMeme(id: string): string {
|
||||
const memeUrl = `${process.env.SITE_URL}/api/image/${id}`
|
||||
return memeUrl;
|
||||
}
|
||||
|
||||
export async function generateMetadata(
|
||||
{ params }: Props,
|
||||
parent: ResolvingMetadata
|
||||
): Promise<Metadata> {
|
||||
const baseUrl = process.env.SITE_URL!;
|
||||
const meme = getMeme(params.id);
|
||||
|
||||
return {
|
||||
metadataBase: new URL(baseUrl),
|
||||
openGraph: {
|
||||
type: 'website',
|
||||
title: 'Generated Meme',
|
||||
url: `${baseUrl}/memes/${params.id}`,
|
||||
images: [{ url: meme }],
|
||||
},
|
||||
twitter: {
|
||||
title: 'Generated Meme',
|
||||
images: [{ url: meme }],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default function MemePage({ params }: Props) {
|
||||
const meme = getMeme(params.id);
|
||||
|
||||
return (
|
||||
<main className="flex min-h-screen flex-col items-center justify-between p-24">
|
||||
<div className="relative flex flex-col items-center">
|
||||
<div className="relative w-full max-w-2xl aspect-square">
|
||||
<img
|
||||
src={meme}
|
||||
alt='Generated meme'
|
||||
className="object-contain rounded-lg w-full h-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
131
src/app/page.tsx
131
src/app/page.tsx
@ -1,11 +1,14 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState } from 'react'
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import Big from 'big.js';
|
||||
import BN from 'bn.js';
|
||||
|
||||
import WalletHeader from '../components/WalletHeader'
|
||||
import AIServiceCard from '../components/AIServiceCard'
|
||||
import { generateWithFlux, FluxGenerationResult, FLUX_MODELS } from '../services/fluxService'
|
||||
import { processMTMPayment } from '../services/paymentService'
|
||||
import { connectWallet, WalletState } from '../services/walletService'
|
||||
import { connectWallet, disconnectWallet, WalletState } from '../services/walletService'
|
||||
import { WalletType } from '../services/types'
|
||||
|
||||
const Page: React.FC = (): React.ReactElement => {
|
||||
@ -15,7 +18,26 @@ const Page: React.FC = (): React.ReactElement => {
|
||||
type: null
|
||||
})
|
||||
|
||||
const handleConnect = async (walletType: WalletType): Promise<void> => {
|
||||
const [priceMTMFor1USDC, setPriceMTMFor1USDC] = useState<BN>(new BN(0));
|
||||
|
||||
useEffect(() => {
|
||||
const fetchPrice = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/quotes');
|
||||
const data = await response.json();
|
||||
|
||||
// Convert the string back to BN
|
||||
const price = new BN(data.latestMTMAmount);
|
||||
setPriceMTMFor1USDC(price);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch price:', error);
|
||||
}
|
||||
};
|
||||
|
||||
fetchPrice();
|
||||
}, []);
|
||||
|
||||
const handleConnect = async (walletType: WalletType): Promise<void> => {
|
||||
try {
|
||||
const newWalletState = await connectWallet(walletType)
|
||||
setWalletState(newWalletState)
|
||||
@ -29,26 +51,63 @@ const Page: React.FC = (): React.ReactElement => {
|
||||
}
|
||||
}
|
||||
|
||||
const handleDisconnect = async (): Promise<void> => {
|
||||
if (walletState.type) {
|
||||
await disconnectWallet(walletState.type)
|
||||
setWalletState({
|
||||
connected: false,
|
||||
publicKey: null,
|
||||
type: null
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const handleFluxGeneration = (modelId: string, cost: number) => {
|
||||
const roundUpBigNumber = (bnValue: BN): BN => {
|
||||
const bigNumber = new Big(bnValue.toString());
|
||||
const bigNumberInUnits = bigNumber.div(new Big(10 ** 6));
|
||||
|
||||
const roundedUpValue = bigNumberInUnits.round(0, Big.roundUp);
|
||||
|
||||
// Multiply by 10^6 to revert back to original scale
|
||||
const scaledValue = roundedUpValue.mul(new Big(10 ** 6));
|
||||
|
||||
return new BN(scaledValue.toString());
|
||||
};
|
||||
|
||||
const handleFluxGeneration = (modelId: string, cost: BN) => {
|
||||
return async (prompt: string): Promise<FluxGenerationResult> => {
|
||||
if (!walletState.connected || !walletState.publicKey || !window.solflare) {
|
||||
const { connected, publicKey, type } = walletState;
|
||||
|
||||
if (!connected || !publicKey || !type ||
|
||||
(type === 'phantom' && !window.phantom) ||
|
||||
(type === 'solflare' && !window.solflare)) {
|
||||
return { error: 'Wallet not connected' }
|
||||
}
|
||||
|
||||
// Process payment first
|
||||
const paymentResult = await processMTMPayment(
|
||||
walletState.publicKey,
|
||||
cost,
|
||||
walletState.type
|
||||
)
|
||||
try {
|
||||
// Convert cost in USDC to MTM tokens using the price ratio
|
||||
const paymentResult = await processMTMPayment(
|
||||
publicKey,
|
||||
roundUpBigNumber(cost),
|
||||
type
|
||||
)
|
||||
|
||||
if (!paymentResult.success) {
|
||||
return { error: paymentResult.error }
|
||||
if (!paymentResult.success) {
|
||||
return { error: paymentResult.error }
|
||||
}
|
||||
|
||||
const transactionSignature = paymentResult.transactionSignature;
|
||||
|
||||
if (!transactionSignature) {
|
||||
return { error: 'Transaction signature not found' }
|
||||
};
|
||||
|
||||
// Generate image with specified model and transaction reference
|
||||
return generateWithFlux(prompt, modelId, transactionSignature);
|
||||
} catch (error) {
|
||||
console.error('Error in handleFluxGeneration:', error)
|
||||
return { error: error instanceof Error ? error.message : 'Unknown error' }
|
||||
}
|
||||
|
||||
// Then generate image with specified model
|
||||
return generateWithFlux(prompt, modelId)
|
||||
}
|
||||
}
|
||||
|
||||
@ -63,26 +122,35 @@ const Page: React.FC = (): React.ReactElement => {
|
||||
<p className="text-gray-400 text-lg mb-8">
|
||||
Use MTM to generate memes
|
||||
</p>
|
||||
|
||||
|
||||
<WalletHeader
|
||||
walletState={walletState}
|
||||
onConnect={handleConnect}
|
||||
/>
|
||||
onDisconnect={handleDisconnect}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Flux Models Grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-6">
|
||||
{FLUX_MODELS.map((model) => (
|
||||
<AIServiceCard
|
||||
key={model.modelId}
|
||||
title={model.name}
|
||||
description={model.description}
|
||||
tokenCost={model.cost}
|
||||
isWalletConnected={walletState.connected}
|
||||
onGenerate={handleFluxGeneration(model.modelId, model.cost)}
|
||||
/>
|
||||
))}
|
||||
|
||||
{FLUX_MODELS.map((model) => {
|
||||
// Convert cost from number to BN
|
||||
const scaledModelCost = new BN(model.cost * 100);
|
||||
const priceMTM = scaledModelCost.mul(priceMTMFor1USDC).div(new BN(100));
|
||||
|
||||
return (
|
||||
<AIServiceCard
|
||||
key={model.modelId}
|
||||
title={model.name}
|
||||
description={model.description}
|
||||
isWalletConnected={walletState.connected}
|
||||
onGenerate={handleFluxGeneration(
|
||||
model.modelId,
|
||||
priceMTM
|
||||
)}
|
||||
priceMTM={priceMTM}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{/* Coming Soon Card */}
|
||||
<div className="relative bg-gray-800/50 backdrop-blur-lg rounded-2xl shadow-xl border border-gray-700/50 overflow-hidden group">
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-yellow-500/10 to-orange-500/10 opacity-50"></div>
|
||||
@ -98,12 +166,11 @@ const Page: React.FC = (): React.ReactElement => {
|
||||
<span className="text-orange-300 text-sm">TBD</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
<button
|
||||
disabled
|
||||
className="w-full bg-gradient-to-r from-yellow-500/50 to-orange-500/50
|
||||
text-white/50 font-semibold py-4 px-6 rounded-xl
|
||||
className="w-full bg-gradient-to-r from-yellow-500/50 to-orange-500/50
|
||||
text-white/50 font-semibold py-4 px-6 rounded-xl
|
||||
cursor-not-allowed opacity-50"
|
||||
>
|
||||
Coming Soon
|
||||
|
16
src/app/submit-tweets/[secret]/page.tsx
Normal file
16
src/app/submit-tweets/[secret]/page.tsx
Normal file
@ -0,0 +1,16 @@
|
||||
'use client'
|
||||
|
||||
import TweetUrlForm from "../../../components/TweetUrlForm";
|
||||
|
||||
interface Props {
|
||||
params: { secret: string };
|
||||
}
|
||||
|
||||
export default function SubmitTweetPage({params}: Props) {
|
||||
|
||||
return (
|
||||
<div className="flex flex-row justify-center items-center">
|
||||
<TweetUrlForm secret={params.secret} />
|
||||
</div>
|
||||
);
|
||||
}
|
@ -1,36 +1,51 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState } from 'react'
|
||||
import BN from 'bn.js';
|
||||
import Big from 'big.js';
|
||||
import dynamic from 'next/dynamic'
|
||||
|
||||
import { generateTweetText } from '../utils/tweetMessage';
|
||||
|
||||
interface AIServiceCardProps {
|
||||
title: string
|
||||
description: string
|
||||
tokenCost: number
|
||||
isWalletConnected: boolean
|
||||
onGenerate: (prompt: string) => Promise<{ imageUrl?: string, error?: string }>
|
||||
onGenerate: (prompt: string) => Promise<{ imageUrl?: string, transactionSignature?: string, error?: string }>
|
||||
priceMTM: BN
|
||||
}
|
||||
|
||||
interface GenerationState {
|
||||
loading: boolean
|
||||
processing: boolean
|
||||
imageUrl: string | null
|
||||
transactionSignature: string | null
|
||||
error: string | null
|
||||
}
|
||||
|
||||
const baseUnitToWholeNumber = (value: BN, decimals: number): string => {
|
||||
const bigValue = new Big(value.toString());
|
||||
const factor = new Big(10).pow(decimals);
|
||||
|
||||
return bigValue.div(factor).round(0, Big.roundUp).toFixed(0);
|
||||
}
|
||||
|
||||
const AIServiceCard: React.FC<AIServiceCardProps> = ({
|
||||
title,
|
||||
description,
|
||||
tokenCost,
|
||||
isWalletConnected,
|
||||
onGenerate
|
||||
onGenerate,
|
||||
priceMTM
|
||||
}) => {
|
||||
const [inputText, setInputText] = useState<string>('')
|
||||
const [generationState, setGenerationState] = useState<GenerationState>({
|
||||
loading: false,
|
||||
processing: false,
|
||||
imageUrl: null,
|
||||
transactionSignature: null,
|
||||
error: null,
|
||||
})
|
||||
const [isImageLoaded, setIsImageLoaded] = useState(false);
|
||||
|
||||
const handleGenerate = async (): Promise<void> => {
|
||||
if (!inputText || !isWalletConnected) return
|
||||
@ -43,21 +58,27 @@ const AIServiceCard: React.FC<AIServiceCardProps> = ({
|
||||
|
||||
try {
|
||||
const result = await onGenerate(inputText)
|
||||
|
||||
|
||||
if (result.error) {
|
||||
setGenerationState({
|
||||
...generationState,
|
||||
loading: false,
|
||||
error: result.error,
|
||||
})
|
||||
// Reload the page to get latest prices
|
||||
setTimeout(() => {
|
||||
window.location.reload();
|
||||
}, 3000);
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (result.imageUrl) {
|
||||
if (result.imageUrl && result.transactionSignature) {
|
||||
setGenerationState({
|
||||
loading: false,
|
||||
processing: false,
|
||||
imageUrl: result.imageUrl,
|
||||
transactionSignature: result.transactionSignature,
|
||||
error: null,
|
||||
})
|
||||
} else {
|
||||
@ -72,6 +93,16 @@ const AIServiceCard: React.FC<AIServiceCardProps> = ({
|
||||
}
|
||||
}
|
||||
|
||||
const generateTwitterShareUrl = (imageUrl: string, transactionSignature: string): string => {
|
||||
const baseUrl = window.location.href;
|
||||
const cid = imageUrl.split("/image/")[1];
|
||||
const memePageUrl = `${baseUrl}memes/${cid}`;
|
||||
|
||||
const tweetText = generateTweetText(transactionSignature, process.env.NEXT_PUBLIC_TWITTER_HANDLE)
|
||||
|
||||
return `https://twitter.com/intent/tweet?text=${encodeURIComponent(tweetText)}&url=${encodeURIComponent(memePageUrl)}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full bg-gray-800/50 backdrop-blur-lg rounded-2xl shadow-xl border border-gray-700/50 mb-8">
|
||||
<div className="p-6">
|
||||
@ -81,7 +112,7 @@ const AIServiceCard: React.FC<AIServiceCardProps> = ({
|
||||
</h2>
|
||||
<p className="text-gray-400 mt-2">{description}</p>
|
||||
<div className="mt-2 inline-block px-3 py-1 bg-green-500/20 rounded-full text-green-300 text-sm">
|
||||
Cost: {tokenCost} MTM
|
||||
Cost: {priceMTM ? baseUnitToWholeNumber(priceMTM, 6) : '...'} MTM
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -91,8 +122,8 @@ const AIServiceCard: React.FC<AIServiceCardProps> = ({
|
||||
onChange={(e) => setInputText(e.target.value)}
|
||||
placeholder="Enter your prompt here..."
|
||||
disabled={!isWalletConnected}
|
||||
className="w-full bg-gray-900/50 text-gray-100 border border-gray-700 rounded-xl p-4
|
||||
placeholder-gray-500 focus:border-green-500 focus:ring-2 focus:ring-green-500/20
|
||||
className="w-full bg-gray-900/50 text-gray-100 border border-gray-700 rounded-xl p-4
|
||||
placeholder-gray-500 focus:border-green-500 focus:ring-2 focus:ring-green-500/20
|
||||
focus:outline-none min-h-[120px] transition-all duration-200
|
||||
disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
rows={4}
|
||||
@ -100,12 +131,12 @@ const AIServiceCard: React.FC<AIServiceCardProps> = ({
|
||||
<button
|
||||
onClick={handleGenerate}
|
||||
disabled={!isWalletConnected || generationState.loading || !inputText}
|
||||
className="w-full bg-gradient-to-r from-green-500 to-emerald-500 hover:from-green-600
|
||||
hover:to-emerald-600 text-white font-semibold py-4 px-6 rounded-xl
|
||||
transition-all duration-200 shadow-lg hover:shadow-green-500/25
|
||||
className="w-full bg-gradient-to-r from-green-500 to-emerald-500 hover:from-green-600
|
||||
hover:to-emerald-600 text-white font-semibold py-4 px-6 rounded-xl
|
||||
transition-all duration-200 shadow-lg hover:shadow-green-500/25
|
||||
disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:shadow-none"
|
||||
>
|
||||
{generationState.loading ? 'Processing...' : `Pay ${tokenCost} MTM & Generate`}
|
||||
{generationState.loading ? 'Processing...' : `Pay ${priceMTM ? baseUnitToWholeNumber(priceMTM, 6) : '...'} MTM & Generate`}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@ -115,13 +146,27 @@ const AIServiceCard: React.FC<AIServiceCardProps> = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{generationState.imageUrl && (
|
||||
{generationState.imageUrl && generationState.transactionSignature && (
|
||||
<div className="mt-4">
|
||||
{!isImageLoaded && <p>Loading image...</p>}
|
||||
<img
|
||||
src={generationState.imageUrl}
|
||||
alt="Generated content"
|
||||
className="w-full h-auto rounded-xl shadow-2xl"
|
||||
onLoad={() => setIsImageLoaded(true)}
|
||||
/>
|
||||
{isImageLoaded &&
|
||||
<div className="mt-4 text-center">
|
||||
<a
|
||||
href={generateTwitterShareUrl(generationState.imageUrl, generationState.transactionSignature)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-block w-full bg-blue-500 hover:bg-blue-600 text-white font-semibold py-4 px-6 rounded-xl transition-all duration-200 shadow-lg hover:shadow-blue-500/25"
|
||||
>
|
||||
Share on Twitter
|
||||
</a>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@ -129,4 +174,6 @@ const AIServiceCard: React.FC<AIServiceCardProps> = ({
|
||||
)
|
||||
}
|
||||
|
||||
export default AIServiceCard
|
||||
export default dynamic(() => Promise.resolve(AIServiceCard), {
|
||||
ssr: false
|
||||
})
|
||||
|
78
src/components/TweetUrlForm.tsx
Normal file
78
src/components/TweetUrlForm.tsx
Normal file
@ -0,0 +1,78 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState } from 'react'
|
||||
|
||||
const TweetUrlForm: React.FC<{secret: string}> = ({secret}) => {
|
||||
const [inputText, setInputText] = useState<string>('')
|
||||
|
||||
const [isSubmitted, setIsSubmitted] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [submitError, setSubmitError] = useState<string | null>(null);
|
||||
const [escrow, setEscrow] = useState('');
|
||||
|
||||
const handleSubmit = async (): Promise<void> => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const response = await fetch('/api/tweet', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ tweetUrl: inputText, secret }),
|
||||
})
|
||||
|
||||
const parsedResponse = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
setSubmitError(parsedResponse.error);
|
||||
throw new Error(`Failed to submit tweet: ${response.statusText}`);
|
||||
}
|
||||
|
||||
if (parsedResponse.data && parsedResponse.data.escrow) {
|
||||
setEscrow(parsedResponse.data.escrow);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
setSubmitError(error.message);
|
||||
console.error('Failed to submit tweet:', error);
|
||||
} finally {
|
||||
setIsSubmitted(true);
|
||||
setLoading(false);
|
||||
setInputText('');
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-gray-800/50 backdrop-blur-lg rounded-2xl shadow-xl border border-gray-700/50 mb-8 mx-auto my-10">
|
||||
<div className="p-6">
|
||||
|
||||
<div className="space-y-4">
|
||||
{isSubmitted ? submitError ? <p className='text-red-500'>Submission failed: {submitError}</p> : <p className='text-green-500'>Tweet submitted!</p> : <></>}
|
||||
{escrow && <p className='text-green-500'>Lock created successfully: {escrow}</p>}
|
||||
<textarea
|
||||
value={inputText}
|
||||
onChange={(e) => setInputText(e.target.value)}
|
||||
placeholder="Enter the tweet URL here..."
|
||||
className="w-full bg-gray-900/50 text-gray-100 border border-gray-700 rounded-xl p-4
|
||||
placeholder-gray-500 focus:border-green-500 focus:ring-2 focus:ring-green-500/20
|
||||
focus:outline-none min-h-[120px] transition-all duration-200
|
||||
disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
rows={4}
|
||||
/>
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
className="w-full bg-gradient-to-r from-green-500 to-emerald-500 hover:from-green-600
|
||||
hover:to-emerald-600 text-white font-semibold py-4 px-6 rounded-xl
|
||||
transition-all duration-200 shadow-lg hover:shadow-green-500/25
|
||||
disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:shadow-none"
|
||||
>
|
||||
{loading ? 'Submitting...' : 'Submit' }
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default TweetUrlForm
|
@ -6,9 +6,10 @@ import { WalletState, SUPPORTED_WALLETS } from '../services/walletService'
|
||||
interface WalletHeaderProps {
|
||||
walletState: WalletState
|
||||
onConnect: (walletType: string) => Promise<void>
|
||||
onDisconnect: () => Promise<void>
|
||||
}
|
||||
|
||||
const WalletHeader: React.FC<WalletHeaderProps> = ({ walletState, onConnect }) => {
|
||||
const WalletHeader: React.FC<WalletHeaderProps> = ({ walletState, onConnect, onDisconnect }) => {
|
||||
return (
|
||||
<div className="w-full bg-slate-900/50 backdrop-blur-lg rounded-xl shadow-lg border border-orange-800/50 mb-8 p-4">
|
||||
{!walletState.connected ? (
|
||||
@ -26,11 +27,17 @@ const WalletHeader: React.FC<WalletHeaderProps> = ({ walletState, onConnect }) =
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-slate-300">Connected Wallet</span>
|
||||
<span className="px-3 py-1 bg-amber-500/20 rounded-full text-amber-200 text-sm">
|
||||
<div className="flex flex-col sm:flex-row items-center justify-between">
|
||||
<span className="text-slate-300 mb-2 sm:mb-0">Connected Wallet</span>
|
||||
<span className="px-3 py-1 bg-amber-500/20 rounded-full text-amber-200 text-sm mb-2 sm:mb-0">
|
||||
{walletState.publicKey?.slice(0, 22)}...
|
||||
</span>
|
||||
<button
|
||||
onClick={onDisconnect}
|
||||
className="bg-red-500 hover:bg-red-600 text-white font-semibold py-2 px-4 rounded-lg transition-all duration-200 shadow-lg hover:shadow-red-500/25"
|
||||
>
|
||||
Disconnect
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
27
src/data-source.ts
Normal file
27
src/data-source.ts
Normal file
@ -0,0 +1,27 @@
|
||||
import { DataSource } from 'typeorm';
|
||||
import assert from 'assert';
|
||||
|
||||
export async function initializeDataSource() {
|
||||
try {
|
||||
const DB_PATH = process.env.DB_PATH;
|
||||
assert(DB_PATH, 'DB_PATH not set');
|
||||
|
||||
console.log('Initializing Data Source');
|
||||
|
||||
const appDataSource = new DataSource({
|
||||
type: 'sqlite',
|
||||
database: DB_PATH,
|
||||
synchronize: true,
|
||||
logging: false,
|
||||
entities: [global.entities.Payment, global.entities.Tweet],
|
||||
migrations: [],
|
||||
subscribers: [],
|
||||
});
|
||||
|
||||
await appDataSource.initialize();
|
||||
return appDataSource;
|
||||
} catch (err) {
|
||||
console.error('Error during Data Source initialization:', err);
|
||||
throw err;
|
||||
}
|
||||
}
|
10
src/entity/Payment.ts
Normal file
10
src/entity/Payment.ts
Normal file
@ -0,0 +1,10 @@
|
||||
import { Entity, PrimaryGeneratedColumn, Column } from 'typeorm';
|
||||
|
||||
@Entity()
|
||||
export class Payment {
|
||||
@PrimaryGeneratedColumn()
|
||||
id!: number;
|
||||
|
||||
@Column({ unique: true })
|
||||
transactionSignature!: string;
|
||||
}
|
19
src/entity/Tweet.ts
Normal file
19
src/entity/Tweet.ts
Normal file
@ -0,0 +1,19 @@
|
||||
import { Entity, PrimaryGeneratedColumn, Column } from 'typeorm';
|
||||
|
||||
@Entity()
|
||||
export class Tweet {
|
||||
@PrimaryGeneratedColumn()
|
||||
id!: number;
|
||||
|
||||
@Column({ type: 'text', unique: true, nullable: true })
|
||||
url!: string | null;
|
||||
|
||||
@Column({ unique: true })
|
||||
transactionSignature!: string;
|
||||
|
||||
@Column({ type: 'boolean', nullable: true })
|
||||
isLockCreated!: boolean | null;
|
||||
|
||||
@Column({ type: 'text', unique: true, nullable: true })
|
||||
lockEscrow!: string | null;
|
||||
}
|
207
src/locker-utils/index.ts
Normal file
207
src/locker-utils/index.ts
Normal file
@ -0,0 +1,207 @@
|
||||
/**
|
||||
* Methods from jup-lock:
|
||||
* - createLockerProgram
|
||||
* - deriveEscrow
|
||||
* - createVestingPlanV2
|
||||
* Reference: https://github.com/jup-ag/jup-lock/blob/main/tests/locker_utils/index.ts
|
||||
*/
|
||||
|
||||
import assert from 'assert';
|
||||
import 'dotenv/config';
|
||||
|
||||
import {
|
||||
ASSOCIATED_TOKEN_PROGRAM_ID,
|
||||
createAssociatedTokenAccountInstruction,
|
||||
getAssociatedTokenAddressSync,
|
||||
TOKEN_2022_PROGRAM_ID,
|
||||
} from '@solana/spl-token';
|
||||
import {
|
||||
AnchorProvider,
|
||||
BN,
|
||||
Program,
|
||||
Wallet,
|
||||
web3,
|
||||
workspace,
|
||||
} from '@coral-xyz/anchor';
|
||||
import { AccountMeta, Connection, TransactionExpiredTimeoutError } from '@solana/web3.js';
|
||||
|
||||
// TODO: Generate type file from IDL json
|
||||
import { Locker } from '../../target/types/locker';
|
||||
import { TokenExtensionUtil } from './token-2022/token-extensions';
|
||||
import {
|
||||
OptionRemainingAccountsInfoData,
|
||||
RemainingAccountsBuilder,
|
||||
RemainingAccountsType,
|
||||
} from './token-2022/remaining-accounts';
|
||||
|
||||
assert(process.env.NEXT_PUBLIC_SOLANA_RPC_URL);
|
||||
assert(process.env.MAX_RETRIES_FOR_LOCK_TX);
|
||||
|
||||
const connection = new Connection(process.env.NEXT_PUBLIC_SOLANA_RPC_URL);
|
||||
const MAX_RETRIES = Number(process.env.MAX_RETRIES_FOR_LOCK_TX);
|
||||
|
||||
export function createLockerProgram(wallet: Wallet): Program<Locker> {
|
||||
const provider = new AnchorProvider(connection, wallet, {
|
||||
maxRetries: 3,
|
||||
});
|
||||
provider.opts.commitment = 'confirmed';
|
||||
|
||||
return workspace.Locker as Program<Locker>;
|
||||
}
|
||||
|
||||
export function deriveEscrow(base: web3.PublicKey, programId: web3.PublicKey) {
|
||||
return web3.PublicKey.findProgramAddressSync(
|
||||
[Buffer.from('escrow'), base.toBuffer()],
|
||||
programId
|
||||
);
|
||||
}
|
||||
|
||||
export interface CreateVestingPlanParams {
|
||||
ownerKeypair: web3.Keypair;
|
||||
tokenMint: web3.PublicKey;
|
||||
isAssertion: boolean;
|
||||
vestingStartTime: BN;
|
||||
cliffTime: BN;
|
||||
frequency: BN;
|
||||
cliffUnlockAmount: BN;
|
||||
amountPerPeriod: BN;
|
||||
numberOfPeriod: BN;
|
||||
recipient: web3.PublicKey;
|
||||
updateRecipientMode: number;
|
||||
cancelMode: number;
|
||||
tokenProgram?: web3.PublicKey;
|
||||
}
|
||||
|
||||
// V2 instructions
|
||||
export async function createVestingPlanV2(params: CreateVestingPlanParams) {
|
||||
let {
|
||||
ownerKeypair,
|
||||
tokenMint,
|
||||
isAssertion,
|
||||
vestingStartTime,
|
||||
cliffTime,
|
||||
frequency,
|
||||
cliffUnlockAmount,
|
||||
amountPerPeriod,
|
||||
numberOfPeriod,
|
||||
recipient,
|
||||
updateRecipientMode,
|
||||
cancelMode,
|
||||
tokenProgram,
|
||||
} = params;
|
||||
|
||||
const program = createLockerProgram(new Wallet(ownerKeypair));
|
||||
|
||||
const baseKP = web3.Keypair.generate();
|
||||
|
||||
let [escrow] = deriveEscrow(baseKP.publicKey, program.programId);
|
||||
|
||||
const senderToken = getAssociatedTokenAddressSync(
|
||||
tokenMint,
|
||||
ownerKeypair.publicKey,
|
||||
false,
|
||||
tokenProgram,
|
||||
ASSOCIATED_TOKEN_PROGRAM_ID
|
||||
);
|
||||
|
||||
const escrowToken = getAssociatedTokenAddressSync(
|
||||
tokenMint,
|
||||
escrow,
|
||||
true,
|
||||
tokenProgram,
|
||||
ASSOCIATED_TOKEN_PROGRAM_ID
|
||||
);
|
||||
|
||||
let remainingAccountsInfo: OptionRemainingAccountsInfoData | null = null;
|
||||
let remainingAccounts: AccountMeta[] = [];
|
||||
if (tokenProgram == TOKEN_2022_PROGRAM_ID) {
|
||||
let inputTransferHookAccounts =
|
||||
await TokenExtensionUtil.getExtraAccountMetasForTransferHook(
|
||||
program.provider.connection,
|
||||
tokenMint,
|
||||
senderToken,
|
||||
escrowToken,
|
||||
ownerKeypair.publicKey,
|
||||
TOKEN_2022_PROGRAM_ID
|
||||
);
|
||||
|
||||
[remainingAccountsInfo, remainingAccounts] = new RemainingAccountsBuilder()
|
||||
.addSlice(
|
||||
RemainingAccountsType.TransferHookEscrow,
|
||||
inputTransferHookAccounts
|
||||
)
|
||||
.build() as [OptionRemainingAccountsInfoData, AccountMeta[]];
|
||||
}
|
||||
|
||||
assert(tokenProgram);
|
||||
|
||||
let attempt = 0;
|
||||
|
||||
while (attempt < MAX_RETRIES) {
|
||||
try {
|
||||
await program.methods
|
||||
.createVestingEscrowV2(
|
||||
{
|
||||
vestingStartTime,
|
||||
cliffTime,
|
||||
frequency,
|
||||
cliffUnlockAmount,
|
||||
amountPerPeriod,
|
||||
numberOfPeriod,
|
||||
updateRecipientMode,
|
||||
cancelMode,
|
||||
},
|
||||
remainingAccountsInfo
|
||||
)
|
||||
.accounts({
|
||||
base: baseKP.publicKey,
|
||||
senderToken,
|
||||
escrowToken,
|
||||
recipient,
|
||||
tokenMint,
|
||||
sender: ownerKeypair.publicKey,
|
||||
tokenProgram,
|
||||
systemProgram: web3.SystemProgram.programId,
|
||||
escrow,
|
||||
} as any)
|
||||
.remainingAccounts(remainingAccounts ? remainingAccounts : [])
|
||||
.preInstructions([
|
||||
createAssociatedTokenAccountInstruction(
|
||||
ownerKeypair.publicKey,
|
||||
escrowToken,
|
||||
escrow,
|
||||
tokenMint,
|
||||
tokenProgram,
|
||||
ASSOCIATED_TOKEN_PROGRAM_ID
|
||||
),
|
||||
])
|
||||
.signers([baseKP, ownerKeypair])
|
||||
.rpc();
|
||||
|
||||
return escrow; // Success, return escrow
|
||||
|
||||
} catch (error) {
|
||||
console.error(`Attempt ${attempt + 1} Transaction confirmation delayed`);
|
||||
|
||||
// If transaction expired, check confirmation
|
||||
if (error instanceof TransactionExpiredTimeoutError) {
|
||||
console.log('Checking if transaction was confirmed...');
|
||||
const confirmedTransaction = await program.provider.connection.getTransaction(error.signature, {
|
||||
commitment: 'confirmed',
|
||||
maxSupportedTransactionVersion: 0
|
||||
});
|
||||
|
||||
if (confirmedTransaction !== null) {
|
||||
return escrow;
|
||||
}
|
||||
} else {
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
attempt++;
|
||||
console.log(`Attempt ${attempt + 1} Transaction failed, retrying...`)
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error("Transaction failed after maximum retries");
|
||||
}
|
50
src/locker-utils/token-2022/remaining-accounts.ts
Normal file
50
src/locker-utils/token-2022/remaining-accounts.ts
Normal file
@ -0,0 +1,50 @@
|
||||
// Reference: https://github.com/jup-ag/jup-lock/blob/main/tests/locker_utils/index.ts
|
||||
|
||||
import { AccountMeta } from '@solana/web3.js';
|
||||
|
||||
export enum RemainingAccountsType {
|
||||
TransferHookEscrow = 'transferHookEscrow',
|
||||
}
|
||||
|
||||
type RemainingAccountsAnchorType = { transferHookEscrow: {} };
|
||||
|
||||
export type RemainingAccountsSliceData = {
|
||||
accountsType: RemainingAccountsAnchorType;
|
||||
length: number;
|
||||
};
|
||||
|
||||
export type RemainingAccountsInfoData = {
|
||||
slices: RemainingAccountsSliceData[];
|
||||
};
|
||||
|
||||
// Option<RemainingAccountsInfoData> on Rust
|
||||
// null is treated as None in Rust. undefined doesn't work.
|
||||
export type OptionRemainingAccountsInfoData = RemainingAccountsInfoData | null;
|
||||
|
||||
export class RemainingAccountsBuilder {
|
||||
private remainingAccounts: AccountMeta[] = [];
|
||||
private slices: RemainingAccountsSliceData[] = [];
|
||||
|
||||
constructor() {}
|
||||
|
||||
addSlice(
|
||||
accountsType: RemainingAccountsType,
|
||||
accounts?: AccountMeta[]
|
||||
): this {
|
||||
if (!accounts || accounts.length === 0) return this;
|
||||
|
||||
this.slices.push({
|
||||
accountsType: { [accountsType]: {} } as RemainingAccountsAnchorType,
|
||||
length: accounts.length,
|
||||
});
|
||||
this.remainingAccounts.push(...accounts);
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
build(): [OptionRemainingAccountsInfoData, AccountMeta[] | undefined] {
|
||||
return this.slices.length === 0
|
||||
? [null, undefined]
|
||||
: [{ slices: this.slices }, this.remainingAccounts];
|
||||
}
|
||||
}
|
57
src/locker-utils/token-2022/token-extensions.ts
Normal file
57
src/locker-utils/token-2022/token-extensions.ts
Normal file
@ -0,0 +1,57 @@
|
||||
// Reference: https://github.com/jup-ag/jup-lock/blob/main/tests/locker_utils/index.ts
|
||||
|
||||
import {
|
||||
AccountMeta,
|
||||
Connection,
|
||||
PublicKey,
|
||||
TransactionInstruction,
|
||||
} from '@solana/web3.js';
|
||||
import {
|
||||
addExtraAccountsToInstruction,
|
||||
getMint,
|
||||
getTransferHook,
|
||||
TOKEN_2022_PROGRAM_ID,
|
||||
} from '@solana/spl-token';
|
||||
|
||||
export class TokenExtensionUtil {
|
||||
public static async getExtraAccountMetasForTransferHook(
|
||||
connection: Connection,
|
||||
tokenMint: PublicKey,
|
||||
source: PublicKey,
|
||||
destination: PublicKey,
|
||||
owner: PublicKey,
|
||||
tokenProgram: PublicKey
|
||||
): Promise<AccountMeta[] | undefined> {
|
||||
let mint = await getMint(connection, tokenMint, 'confirmed', tokenProgram);
|
||||
const transferHook = getTransferHook(mint);
|
||||
|
||||
if (!transferHook) return undefined;
|
||||
|
||||
const instruction = new TransactionInstruction({
|
||||
programId: TOKEN_2022_PROGRAM_ID,
|
||||
keys: [
|
||||
{ pubkey: source, isSigner: false, isWritable: false },
|
||||
{
|
||||
pubkey: tokenMint,
|
||||
isSigner: false,
|
||||
isWritable: false,
|
||||
},
|
||||
{ pubkey: destination, isSigner: false, isWritable: false },
|
||||
{ pubkey: owner, isSigner: false, isWritable: false },
|
||||
{ pubkey: owner, isSigner: false, isWritable: false },
|
||||
],
|
||||
});
|
||||
|
||||
// Note:
|
||||
await addExtraAccountsToInstruction(
|
||||
connection,
|
||||
instruction,
|
||||
tokenMint,
|
||||
'confirmed',
|
||||
transferHook.programId,
|
||||
);
|
||||
|
||||
const extraAccountMetas = instruction.keys.slice(5);
|
||||
return extraAccountMetas.length > 0 ? extraAccountMetas : undefined;
|
||||
}
|
||||
}
|
@ -1,6 +1,7 @@
|
||||
export interface FluxGenerationResult {
|
||||
imageUrl?: string
|
||||
error?: string
|
||||
transactionSignature?: string
|
||||
}
|
||||
|
||||
export interface FluxModelConfig {
|
||||
@ -16,25 +17,26 @@ export const FLUX_MODELS: FluxModelConfig[] = [
|
||||
modelId: "fal-ai/flux/schnell",
|
||||
name: "Schnell",
|
||||
description: "Fast meme generator",
|
||||
cost: 300
|
||||
cost: 0.05
|
||||
},
|
||||
{
|
||||
modelId: "fal-ai/recraft-v3",
|
||||
name: "Recraft",
|
||||
description: "Advanced meme generator",
|
||||
cost: 400
|
||||
cost: 0.10
|
||||
},
|
||||
{
|
||||
modelId: "fal-ai/stable-diffusion-v35-large",
|
||||
name: "Marquee",
|
||||
description: "Best meme generator",
|
||||
cost: 500
|
||||
cost: 0.15
|
||||
}
|
||||
]
|
||||
|
||||
export async function generateWithFlux(
|
||||
prompt: string,
|
||||
modelId: string
|
||||
modelId: string,
|
||||
transactionSignature: string,
|
||||
): Promise<FluxGenerationResult> {
|
||||
try {
|
||||
const response = await fetch('/api/flux', {
|
||||
@ -44,19 +46,20 @@ export async function generateWithFlux(
|
||||
},
|
||||
body: JSON.stringify({
|
||||
prompt,
|
||||
modelId
|
||||
modelId,
|
||||
transactionSignature,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to generate image')
|
||||
throw new Error('Failed to generate image, reloading...')
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
console.log('Raw Flux response:', data)
|
||||
|
||||
if (data.imageUrl) {
|
||||
return { imageUrl: data.imageUrl }
|
||||
return { imageUrl: data.imageUrl, transactionSignature }
|
||||
} else {
|
||||
console.error('Unexpected response structure:', data)
|
||||
throw new Error('Invalid response format from Flux API')
|
||||
|
@ -1,17 +1,24 @@
|
||||
import { Connection, PublicKey, Transaction, SystemProgram } from '@solana/web3.js'
|
||||
import {
|
||||
TOKEN_PROGRAM_ID,
|
||||
createTransferInstruction,
|
||||
getAssociatedTokenAddress,
|
||||
import assert from 'assert';
|
||||
import BN from 'bn.js';
|
||||
|
||||
import { Connection, PublicKey, Transaction } from '@solana/web3.js'
|
||||
import {
|
||||
TOKEN_PROGRAM_ID,
|
||||
createTransferInstruction,
|
||||
createAssociatedTokenAccountInstruction,
|
||||
ASSOCIATED_TOKEN_PROGRAM_ID
|
||||
} from '@solana/spl-token'
|
||||
|
||||
import { WalletType } from './types'
|
||||
|
||||
const MTM_TOKEN_MINT: string = '97RggLo3zV5kFGYW4yoQTxr4Xkz4Vg2WPHzNYXXWpump'
|
||||
const PAYMENT_RECEIVER_ADDRESS: string = 'FFDx3SdAEeXrp6BTmStB4BDHpctGsaasZq4FFcowRobY'
|
||||
const SOLANA_RPC_URL: string = 'https://young-radial-orb.solana-mainnet.quiknode.pro/67612b364664616c29514e551bf5de38447ca3d4'
|
||||
const SOLANA_WEBSOCKET_URL: string = 'wss://young-radial-orb.solana-mainnet.quiknode.pro/67612b364664616c29514e551bf5de38447ca3d4'
|
||||
assert(process.env.NEXT_PUBLIC_SOLANA_RPC_URL, 'SOLANA_RPC_URL is required');
|
||||
assert(process.env.NEXT_PUBLIC_MTM_MINT_ADDRESS, 'MTM_MINT_ADDRESS is required');
|
||||
assert(process.env.NEXT_PUBLIC_MTM_RECIPIENT_MULTISIG_ADDRESS, 'MTM_RECIPIENT_MULTISIG_ADDRESS is required');
|
||||
|
||||
const MTM_TOKEN_MINT = process.env.NEXT_PUBLIC_MTM_MINT_ADDRESS;
|
||||
const PAYMENT_RECEIVER_ADDRESS = process.env.NEXT_PUBLIC_MTM_RECIPIENT_MULTISIG_ADDRESS;
|
||||
const SOLANA_RPC_URL = process.env.NEXT_PUBLIC_SOLANA_RPC_URL;
|
||||
const SOLANA_WEBSOCKET_URL = process.env.NEXT_PUBLIC_SOLANA_WEBSOCKET_URL;
|
||||
|
||||
const connection = new Connection(
|
||||
SOLANA_RPC_URL,
|
||||
@ -24,6 +31,7 @@ const connection = new Connection(
|
||||
|
||||
export interface PaymentResult {
|
||||
success: boolean
|
||||
transactionSignature?: string
|
||||
error?: string
|
||||
}
|
||||
|
||||
@ -47,12 +55,12 @@ interface WalletAdapter {
|
||||
|
||||
export async function processMTMPayment(
|
||||
walletPublicKey: string,
|
||||
tokenAmount: number,
|
||||
tokenAmount: BN,
|
||||
walletType: WalletType
|
||||
): Promise<PaymentResult> {
|
||||
try {
|
||||
let wallet: WalletAdapter | null = null;
|
||||
|
||||
|
||||
if (walletType === 'phantom') {
|
||||
wallet = window.phantom?.solana || null;
|
||||
} else if (walletType === 'solflare') {
|
||||
@ -119,12 +127,14 @@ export async function processMTMPayment(
|
||||
)
|
||||
}
|
||||
|
||||
const amountToSend = BigInt(tokenAmount.toString());
|
||||
|
||||
transaction.add(
|
||||
createTransferInstruction(
|
||||
senderATA,
|
||||
receiverATA,
|
||||
senderPublicKey,
|
||||
BigInt(tokenAmount * (10 ** 6))
|
||||
amountToSend
|
||||
)
|
||||
)
|
||||
|
||||
@ -147,11 +157,11 @@ export async function processMTMPayment(
|
||||
throw new Error(`Transaction failed: ${JSON.stringify(confirmation.value.err)}`)
|
||||
}
|
||||
|
||||
return { success: true }
|
||||
return { success: true, transactionSignature: signature };
|
||||
} catch (error) {
|
||||
console.error('Payment error:', error)
|
||||
return {
|
||||
success: false,
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Payment failed'
|
||||
}
|
||||
}
|
||||
|
@ -10,6 +10,7 @@ export interface WalletConfig {
|
||||
type: WalletType
|
||||
name: string
|
||||
connect: () => Promise<{ publicKey: string } | null>
|
||||
disconnect: () => Promise<void>
|
||||
}
|
||||
|
||||
const connectSolflare = async (): Promise<{ publicKey: string } | null> => {
|
||||
@ -18,6 +19,12 @@ const connectSolflare = async (): Promise<{ publicKey: string } | null> => {
|
||||
return window.solflare.publicKey ? { publicKey: window.solflare.publicKey.toString() } : null
|
||||
}
|
||||
|
||||
const disconnectSolflare = async (): Promise<void> => {
|
||||
if (window.solflare) {
|
||||
await window.solflare.disconnect()
|
||||
}
|
||||
}
|
||||
|
||||
const connectPhantom = async (): Promise<{ publicKey: string } | null> => {
|
||||
if (!window.phantom?.solana) return null
|
||||
try {
|
||||
@ -28,16 +35,24 @@ const connectPhantom = async (): Promise<{ publicKey: string } | null> => {
|
||||
}
|
||||
}
|
||||
|
||||
const disconnectPhantom = async (): Promise<void> => {
|
||||
if (window.phantom?.solana) {
|
||||
await window.phantom.solana.disconnect()
|
||||
}
|
||||
}
|
||||
|
||||
export const SUPPORTED_WALLETS: WalletConfig[] = [
|
||||
{
|
||||
type: 'solflare',
|
||||
name: 'Solflare',
|
||||
connect: connectSolflare
|
||||
connect: connectSolflare,
|
||||
disconnect: disconnectSolflare
|
||||
},
|
||||
{
|
||||
type: 'phantom',
|
||||
name: 'Phantom',
|
||||
connect: connectPhantom
|
||||
connect: connectPhantom,
|
||||
disconnect: disconnectPhantom
|
||||
}
|
||||
]
|
||||
|
||||
@ -62,3 +77,10 @@ export async function connectWallet(type: WalletType): Promise<WalletState> {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function disconnectWallet(type: WalletType): Promise<void> {
|
||||
const wallet = SUPPORTED_WALLETS.find(w => w.type === type)
|
||||
if (wallet) {
|
||||
await wallet.disconnect()
|
||||
}
|
||||
}
|
||||
|
89
src/utils/create-lock.ts
Normal file
89
src/utils/create-lock.ts
Normal file
@ -0,0 +1,89 @@
|
||||
import assert from 'assert';
|
||||
import BN from 'bn.js';
|
||||
import 'dotenv/config';
|
||||
import bs58 from 'bs58';
|
||||
import Big from 'big.js';
|
||||
|
||||
import * as anchor from "@coral-xyz/anchor";
|
||||
import {
|
||||
TOKEN_PROGRAM_ID,
|
||||
} from "@solana/spl-token";
|
||||
import { Connection, Keypair, PublicKey } from "@solana/web3.js";
|
||||
|
||||
import { createVestingPlanV2 } from '../locker-utils';
|
||||
|
||||
assert(process.env.NEXT_PUBLIC_SOLANA_RPC_URL);
|
||||
assert(process.env.WSOL_LOCKER_ACCOUNT_PK);
|
||||
assert(process.env.WSOL_MINT_ADDRESS);
|
||||
|
||||
const RPC_ENDPOINT= process.env.NEXT_PUBLIC_SOLANA_RPC_URL;
|
||||
const WSOL_MINT_ADDRESS = process.env.WSOL_MINT_ADDRESS;
|
||||
const WSOL_LOCKER_ACCOUNT_PK = process.env.WSOL_LOCKER_ACCOUNT_PK;
|
||||
|
||||
const userKP = anchor.web3.Keypair.fromSecretKey(bs58.decode(WSOL_LOCKER_ACCOUNT_PK));
|
||||
|
||||
const connection = new Connection(RPC_ENDPOINT);
|
||||
const token = new PublicKey(WSOL_MINT_ADDRESS);
|
||||
|
||||
const provider = new anchor.AnchorProvider(
|
||||
connection,
|
||||
new anchor.Wallet(userKP),
|
||||
// Commitment level required for simulating transaction
|
||||
{ preflightCommitment: 'processed' }
|
||||
);
|
||||
|
||||
anchor.setProvider(provider);
|
||||
|
||||
export async function createLock(tokenLockerKeypair: anchor.web3.Keypair, recipientPubKey: anchor.web3.PublicKey, duration: BN, amount: BN): Promise<anchor.web3.PublicKey | void> {
|
||||
|
||||
if (amount.eq(new BN(0))) {
|
||||
console.log('Invalid Amount');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('Creating a lock...');
|
||||
|
||||
const escrow = await createVestingPlanV2({
|
||||
ownerKeypair: tokenLockerKeypair,
|
||||
vestingStartTime: new BN(Math.floor(Date.now() / 1000) - 60), // Start immediately
|
||||
tokenMint: token,
|
||||
isAssertion: true,
|
||||
cliffTime: duration,
|
||||
frequency: new BN(1), // Not needed since full unlock happens at cliff
|
||||
cliffUnlockAmount: amount, // The entire amount should be released at cliff
|
||||
amountPerPeriod: new BN(0), // No tokens should be released before cliff
|
||||
numberOfPeriod: new BN(1), // Only release tokens once
|
||||
recipient: recipientPubKey,
|
||||
updateRecipientMode: 0,
|
||||
cancelMode: 1, // Only creator can cancel the lock
|
||||
tokenProgram: TOKEN_PROGRAM_ID,
|
||||
});
|
||||
|
||||
if (escrow) {
|
||||
console.log('Lock created successfully:', escrow.toString());
|
||||
}
|
||||
|
||||
return escrow;
|
||||
}
|
||||
|
||||
export async function createRewardLock(recipient: string, txMtmAmount: string) {
|
||||
const { WSOL_LOCKER_ACCOUNT_PK, WSOL_LOCK_DURATION_IN_SECONDS, WSOL_MINT_ADDRESS, NEXT_PUBLIC_MTM_MINT_ADDRESS, REWARD_MULTIPLIER } = process.env;
|
||||
|
||||
if (!WSOL_LOCKER_ACCOUNT_PK || !WSOL_LOCK_DURATION_IN_SECONDS || !WSOL_MINT_ADDRESS || !NEXT_PUBLIC_MTM_MINT_ADDRESS || !REWARD_MULTIPLIER) {
|
||||
throw new Error('Missing required environment variables for creating reward wSOL lock');
|
||||
}
|
||||
|
||||
const lockDuration = new BN(WSOL_LOCK_DURATION_IN_SECONDS).add(new BN(Math.floor(Date.now() / 1000)));
|
||||
const tokenLockerKeypair = Keypair.fromSecretKey(bs58.decode(WSOL_LOCKER_ACCOUNT_PK));
|
||||
const recipientPublicKey = new PublicKey(recipient);
|
||||
|
||||
const url = `https://api.jup.ag/price/v2?ids=${NEXT_PUBLIC_MTM_MINT_ADDRESS}&vsToken=${WSOL_MINT_ADDRESS}`;
|
||||
const response = await fetch(url);
|
||||
const { data } = await response.json();
|
||||
|
||||
const priceWSOLFor1MTM = new Big(data[NEXT_PUBLIC_MTM_MINT_ADDRESS].price).toFixed(9);
|
||||
const mtmAmount = new Big(txMtmAmount).div(new Big(10).pow(6));
|
||||
const wsolAmount = new BN(new Big(mtmAmount).times(priceWSOLFor1MTM).times(new Big(10).pow(9)).times(REWARD_MULTIPLIER).toFixed(0));
|
||||
|
||||
return createLock(tokenLockerKeypair, recipientPublicKey, lockDuration, wsolAmount);
|
||||
}
|
22
src/utils/extractTxInfo.ts
Normal file
22
src/utils/extractTxInfo.ts
Normal file
@ -0,0 +1,22 @@
|
||||
import { TOKEN_PROGRAM_ID } from "@solana/spl-token";
|
||||
import { Connection } from "@solana/web3.js";
|
||||
|
||||
const connection = new Connection(process.env.NEXT_PUBLIC_SOLANA_RPC_URL!);
|
||||
|
||||
export async function extractTxInfo(transactionSignature: string) {
|
||||
const result = await connection.getParsedTransaction(transactionSignature, 'confirmed');
|
||||
if (!result) {
|
||||
throw new Error('Transaction not found');
|
||||
}
|
||||
|
||||
const transferInstruction = result.transaction.message.instructions.find(
|
||||
(instr) => 'parsed' in instr && instr.programId.equals(TOKEN_PROGRAM_ID)
|
||||
);
|
||||
|
||||
if (!transferInstruction || !('parsed' in transferInstruction)) {
|
||||
throw new Error('Transfer instruction not found');
|
||||
}
|
||||
|
||||
const { info: { amount, authority } } = transferInstruction.parsed;
|
||||
return { authority, amount };
|
||||
}
|
19
src/utils/tweetMessage.ts
Normal file
19
src/utils/tweetMessage.ts
Normal file
@ -0,0 +1,19 @@
|
||||
export const generateTweetText = (transactionSignature: string, handle: string | undefined) => {
|
||||
return `Check out this meme that I generated! \n TX Hash: '${transactionSignature}' \n @${handle} \n`;
|
||||
};
|
||||
|
||||
export const extractData = (tweet: string | object) => {
|
||||
const tweetText = typeof tweet === 'string' ? tweet : JSON.stringify(tweet);
|
||||
|
||||
const decodedTweet = tweetText.replace(/'/g, "'").replace(/"/g, '"');
|
||||
|
||||
const urlMatch = decodedTweet.match(/<a href="(https:\/\/t.co\/[^"]+)">/);
|
||||
const txSignatureMatch = decodedTweet.match(/TX Hash: '([^']+)'/);
|
||||
const handleMatch = decodedTweet.match(/@([A-Za-z0-9_]+)/);
|
||||
|
||||
return {
|
||||
memeUrl: urlMatch ? urlMatch[1] : null,
|
||||
txSignature: txSignatureMatch ? txSignatureMatch[1].trim() : null,
|
||||
handle: handleMatch ? handleMatch[1] : null,
|
||||
};
|
||||
};
|
28
src/utils/uploadToPinata.ts
Normal file
28
src/utils/uploadToPinata.ts
Normal file
@ -0,0 +1,28 @@
|
||||
import { PinataSDK } from 'pinata-web3';
|
||||
import 'dotenv/config';
|
||||
import assert from 'assert';
|
||||
|
||||
import { FluxGenerationResult } from '../services/fluxService';
|
||||
|
||||
assert(process.env.PINATA_JWT, "PINATA_JWT is required");
|
||||
assert(process.env.PINATA_GATEWAY, "PINATA_GATEWAY is required");
|
||||
|
||||
const pinata = new PinataSDK({
|
||||
pinataJwt: process.env.PINATA_JWT,
|
||||
pinataGateway: process.env.PINATA_GATEWAY,
|
||||
});
|
||||
|
||||
export async function uploadToPinata(imageUrl: string, transactionSignature: string): Promise<FluxGenerationResult> {
|
||||
try {
|
||||
const upload = await pinata.upload.url(imageUrl, { metadata: { name: transactionSignature }});
|
||||
|
||||
const publicURL = await pinata.gateways.convert(upload.IpfsHash);
|
||||
|
||||
return { imageUrl: publicURL };
|
||||
} catch (error) {
|
||||
console.error('Error uploading to Pinata:', error)
|
||||
return {
|
||||
error: error instanceof Error ? error.message : 'Upload failed'
|
||||
};
|
||||
}
|
||||
}
|
70
src/utils/verifyPayment.ts
Normal file
70
src/utils/verifyPayment.ts
Normal file
@ -0,0 +1,70 @@
|
||||
import assert from 'assert';
|
||||
import BN from 'bn.js';
|
||||
|
||||
import { Connection } from '@solana/web3.js';
|
||||
import { TOKEN_PROGRAM_ID } from '@solana/spl-token';
|
||||
import { extractTxInfo } from './extractTxInfo';
|
||||
|
||||
assert(process.env.NEXT_PUBLIC_SOLANA_RPC_URL, 'SOLANA_RPC_URL is required');
|
||||
|
||||
const SOLANA_RPC_URL = process.env.NEXT_PUBLIC_SOLANA_RPC_URL;
|
||||
const SOLANA_WEBSOCKET_URL = process.env.NEXT_PUBLIC_SOLANA_WEBSOCKET_URL;
|
||||
|
||||
const connection = new Connection(
|
||||
SOLANA_RPC_URL,
|
||||
{
|
||||
commitment: 'confirmed',
|
||||
wsEndpoint: SOLANA_WEBSOCKET_URL,
|
||||
confirmTransactionInitialTimeout: 60000,
|
||||
}
|
||||
);
|
||||
|
||||
export async function isSignatureUsed(transactionSignature: string): Promise<boolean> {
|
||||
const paymentRepository = global.appDataSource.getRepository(global.entities.Payment);
|
||||
const payment = await paymentRepository.findOneBy({ transactionSignature });
|
||||
if (payment) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export async function markSignatureAsUsed(transactionSignature: string): Promise<void> {
|
||||
await global.appDataSource.transaction(async (transactionalEntityManager) => {
|
||||
const paymentRepository = transactionalEntityManager.getRepository(global.entities.Payment);
|
||||
|
||||
// Check if the payment with the given signature already exists
|
||||
const exists = await paymentRepository.exists({ where: { transactionSignature } });
|
||||
|
||||
// If not, create a new payment entry
|
||||
if (!exists) {
|
||||
const newPayment = paymentRepository.create({ transactionSignature });
|
||||
await paymentRepository.save(newPayment);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// TODO: Verify that payment receiver is correct
|
||||
export async function verifyPayment(
|
||||
transactionSignature: string,
|
||||
tokenAmount: BN,
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
// Check if the signature is already used
|
||||
if (await isSignatureUsed(transactionSignature)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const { amount } = await extractTxInfo(transactionSignature);
|
||||
|
||||
const transactionAmount = new BN(amount);
|
||||
|
||||
if (transactionAmount.gte(tokenAmount)) {
|
||||
return true;
|
||||
}
|
||||
console.log('Transaction amount is less than minimum amount. Rejecting request');
|
||||
return false;
|
||||
} catch (error) {
|
||||
console.error('Verification error:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
69
src/utils/verifyTweet.ts
Normal file
69
src/utils/verifyTweet.ts
Normal file
@ -0,0 +1,69 @@
|
||||
import { DataSource, EntityTarget } from 'typeorm';
|
||||
|
||||
import { Tweet } from '../entity/Tweet';
|
||||
import { createRewardLock } from './create-lock';
|
||||
import { extractTxInfo } from './extractTxInfo';
|
||||
|
||||
export async function verifySignatureInTweet(transactionSignature: string): Promise<boolean> {
|
||||
const paymentRepository = global.appDataSource.getRepository(global.entities.Payment);
|
||||
const payment = await paymentRepository.findOneBy({ transactionSignature });
|
||||
|
||||
if (!payment) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const tweetRepository = global.appDataSource.getRepository(global.entities.Tweet);
|
||||
const tweet = await tweetRepository.findOneBy({ transactionSignature });
|
||||
|
||||
if (tweet) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function processTweet(txSignature: string, memeUrl: string | null) {
|
||||
const tweetRepository = (global.appDataSource as DataSource).getRepository(
|
||||
global.entities.Tweet as EntityTarget<Tweet>
|
||||
);
|
||||
|
||||
const tweet = await tweetRepository.save({
|
||||
transactionSignature: txSignature,
|
||||
url: memeUrl,
|
||||
});
|
||||
|
||||
const isFourthUser = tweet.id % 4 === 0;
|
||||
|
||||
try {
|
||||
if (isFourthUser) {
|
||||
const { authority, amount } = await extractTxInfo(txSignature);
|
||||
|
||||
if (!authority || Number(amount) <= 0) {
|
||||
return { error: "Invalid transaction details" };
|
||||
}
|
||||
|
||||
const escrow = await createRewardLock(authority, amount);
|
||||
|
||||
if (!escrow) {
|
||||
throw new Error("Lock not created");
|
||||
}
|
||||
|
||||
await tweetRepository.update(tweet.id, {
|
||||
isLockCreated: true,
|
||||
lockEscrow: escrow.toString()
|
||||
});
|
||||
|
||||
return { success: true, data: { escrow } };
|
||||
}
|
||||
|
||||
return { success: true, message: 'Tweet verified' };
|
||||
} catch (error) {
|
||||
await tweetRepository.update(tweet.id, {
|
||||
isLockCreated: false,
|
||||
});
|
||||
|
||||
console.error('Error locking tokens: ', error);
|
||||
|
||||
throw new Error("Transaction failed.");
|
||||
}
|
||||
}
|
57
styles.css
Normal file
57
styles.css
Normal file
@ -0,0 +1,57 @@
|
||||
/* ...existing code... */
|
||||
|
||||
/* Mobile styles */
|
||||
@media (max-width: 600px) {
|
||||
body {
|
||||
font-size: 14px;
|
||||
}
|
||||
.container {
|
||||
padding: 10px;
|
||||
}
|
||||
.header, .footer {
|
||||
text-align: center;
|
||||
}
|
||||
.content {
|
||||
margin: 0;
|
||||
padding: 10px;
|
||||
}
|
||||
/* Add more styles as needed for mobile responsiveness */
|
||||
}
|
||||
|
||||
/* Tablet styles */
|
||||
@media (min-width: 601px) and (max-width: 1024px) {
|
||||
body {
|
||||
font-size: 16px;
|
||||
}
|
||||
.container {
|
||||
padding: 20px;
|
||||
}
|
||||
.header, .footer {
|
||||
text-align: center;
|
||||
}
|
||||
.content {
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
}
|
||||
/* Add more styles as needed for tablet responsiveness */
|
||||
}
|
||||
|
||||
/* Desktop styles */
|
||||
@media (min-width: 1025px) {
|
||||
body {
|
||||
font-size: 18px;
|
||||
}
|
||||
.container {
|
||||
padding: 30px;
|
||||
}
|
||||
.header, .footer {
|
||||
text-align: left;
|
||||
}
|
||||
.content {
|
||||
margin: 0 auto;
|
||||
padding: 30px;
|
||||
}
|
||||
/* Add more styles as needed for desktop responsiveness */
|
||||
}
|
||||
|
||||
/* ...existing code... */
|
3143
target/idl/locker.json
Normal file
3143
target/idl/locker.json
Normal file
File diff suppressed because it is too large
Load Diff
3149
target/types/locker.ts
Normal file
3149
target/types/locker.ts
Normal file
File diff suppressed because it is too large
Load Diff
@ -16,11 +16,14 @@
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"experimentalDecorators": true,
|
||||
"emitDecoratorMetadata": true,
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
]
|
||||
],
|
||||
"strictNullChecks": true
|
||||
},
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
|
13
tsconfig.server.json
Normal file
13
tsconfig.server.json
Normal file
@ -0,0 +1,13 @@
|
||||
// Reference: https://github.com/vercel/next.js/blob/canary/examples/custom-server/tsconfig.server.json
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"module": "commonjs",
|
||||
"outDir": "dist",
|
||||
"lib": ["es2019"],
|
||||
"target": "es2019",
|
||||
"isolatedModules": false,
|
||||
"noEmit": false
|
||||
},
|
||||
"include": ["server.ts"]
|
||||
}
|
Loading…
Reference in New Issue
Block a user