Compare commits

...

18 Commits

Author SHA1 Message Date
Shreerang Kale
911240877f Take database path from env 2025-02-07 13:31:56 +05:30
d87a6729c5 Implement retry of lock transaction (#16)
Part of https://www.notion.so/Option-to-post-paid-for-memes-to-twitter-x-18ca6b22d4728051804ef4f55065d5ba

Co-authored-by: AdityaSalunkhe21 <adityasalunkhe2204@gmail.com>
Reviewed-on: deep-stack/sol-mem-gen#16
2025-02-07 06:06:37 +00:00
3dd1ff74ab Set tx hash as image file name in pinata (#17)
Part of https://www.notion.so/Upload-generated-image-to-IPFS-190a6b22d47280f1ba81e860b7ca4ae1

Co-authored-by: Shreerang Kale <shreerangkale@gmail.com>
Reviewed-on: deep-stack/sol-mem-gen#17
2025-02-07 06:05:06 +00:00
d3e6f7ad12 Update deploy script to handle records dir (#15)
Part of https://www.notion.so/Option-to-post-paid-for-memes-to-twitter-x-18ca6b22d4728051804ef4f55065d5ba

Reviewed-on: deep-stack/sol-mem-gen#15
Co-authored-by: Prathamesh Musale <prathamesh.musale0@gmail.com>
Co-committed-by: Prathamesh Musale <prathamesh.musale0@gmail.com>
2025-02-06 15:55:10 +00:00
7c95548389 Show escrow public key when tweet is submitted for verification (#14)
Part of https://www.notion.so/Option-to-post-paid-for-memes-to-twitter-x-18ca6b22d4728051804ef4f55065d5ba

Co-authored-by: Shreerang Kale <shreerangkale@gmail.com>
Reviewed-on: deep-stack/sol-mem-gen#14
2025-02-06 14:04:19 +00:00
bdc9987b57 Add page to submit tweets for verification (#13)
Part of https://www.notion.so/Option-to-post-paid-for-memes-to-twitter-x-18ca6b22d4728051804ef4f55065d5ba

Co-authored-by: Shreerang Kale <shreerangkale@gmail.com>
Reviewed-on: deep-stack/sol-mem-gen#13
2025-02-06 13:45:08 +00:00
7300d0133d Create WSOL lock for every fourth verified tweet (#12)
Part of https://www.notion.so/Option-to-post-paid-for-memes-to-twitter-x-18ca6b22d4728051804ef4f55065d5ba

Co-authored-by: AdityaSalunkhe21 <adityasalunkhe2204@gmail.com>
Co-authored-by: Adw8 <adwaitgharpure@gmail.com>
Co-authored-by: IshaVenikar <ishavenikar7@gmail.com>
Reviewed-on: deep-stack/sol-mem-gen#12
Co-authored-by: adwait <adwait@noreply.git.vdb.to>
Co-committed-by: adwait <adwait@noreply.git.vdb.to>
2025-02-06 12:48:52 +00:00
b211e88441 Add script and instructions for deployment (#11)
Part of https://www.notion.so/Option-to-post-paid-for-memes-to-twitter-x-18ca6b22d4728051804ef4f55065d5ba

Co-authored-by: Nabarun <nabarun@deepstacksoft.com>
Reviewed-on: deep-stack/sol-mem-gen#11
Co-authored-by: Prathamesh Musale <prathamesh.musale0@gmail.com>
Co-committed-by: Prathamesh Musale <prathamesh.musale0@gmail.com>
2025-02-06 12:24:57 +00:00
fa168e5c61 Add feature to share generated memes on twitter and verify tweet (#10)
Part of https://www.notion.so/Option-to-post-paid-for-memes-to-twitter-x-18ca6b22d4728051804ef4f55065d5ba

- Generate dynamic page with required img and meta tags
- Add button to share generated meme to twitter

Co-authored-by: Adw8 <adwaitgharpure@gmail.com>
Co-authored-by: IshaVenikar <ishavenikar7@gmail.com>
Co-authored-by: AdityaSalunkhe21 <adityasalunkhe2204@gmail.com>
Co-authored-by: Nabarun <nabarun@deepstacksoft.com>
Reviewed-on: deep-stack/sol-mem-gen#10
Co-authored-by: adwait <adwait@noreply.git.vdb.to>
Co-committed-by: adwait <adwait@noreply.git.vdb.to>
2025-02-06 05:45:02 +00:00
7bdc0a241d Upload generated meme to Pinata (#9)
Part of https://www.notion.so/Deploy-memes-markto-market-188a6b22d47280e38324efab1d3de55f
- Use pinata URL in frontend

Co-authored-by: Prathamesh Musale <prathamesh.musale0@gmail.com>
Co-authored-by: Adw8 <adwaitgharpure@gmail.com>
Reviewed-on: deep-stack/sol-mem-gen#9
Co-authored-by: adwait <adwait@noreply.git.vdb.to>
Co-committed-by: adwait <adwait@noreply.git.vdb.to>
2025-02-04 15:16:24 +00:00
ff48bdb954 Compile TypeScript custom server in start script (#8)
Co-authored-by: Adw8 <adwaitgharpure@gmail.com>
Reviewed-on: deep-stack/sol-mem-gen#8
Co-authored-by: adwait <adwait@noreply.git.vdb.to>
Co-committed-by: adwait <adwait@noreply.git.vdb.to>
2025-01-31 09:13:12 +00:00
36f298ff96 Use MTM price based on USDC for meme generation (#7)
Part of https://www.notion.so/Use-MTM-price-based-on-pool-189a6b22d47280ff9373c89f40b378d1

Co-authored-by: Adw8 <adwaitgharpure@gmail.com>
Co-authored-by: Nabarun <nabarun@deepstacksoft.com>
Co-authored-by: Adwait Gharpure <adwaitgharpure@gmail.com>
Reviewed-on: deep-stack/sol-mem-gen#7
Co-authored-by: adwait <adwait@noreply.git.vdb.to>
Co-committed-by: adwait <adwait@noreply.git.vdb.to>
2025-01-30 15:03:09 +00:00
fa6a88f297 Handle responsiveness in mobile browser (#6)
Part of https://www.notion.so/Add-disconnect-wallet-button-188a6b22d47280db91d5d180c7e151ae

Reviewed-on: deep-stack/sol-mem-gen#6
Co-authored-by: Nabarun <nabarun@deepstacksoft.com>
Co-committed-by: Nabarun <nabarun@deepstacksoft.com>
2025-01-27 14:08:02 +00:00
1582f8887d Add replay protection to meme generation API (#5)
Part of https://www.notion.so/Add-disconnect-wallet-button-188a6b22d47280db91d5d180c7e151ae
- Add sqlite DB for storing tx signatures
- Reject request if tx signature is already in DB

Co-authored-by: Adw8 <adwaitgharpure@gmail.com>
Reviewed-on: deep-stack/sol-mem-gen#5
Co-authored-by: adwait <adwait@noreply.git.vdb.to>
Co-committed-by: adwait <adwait@noreply.git.vdb.to>
2025-01-27 14:02:12 +00:00
6e6c10edc1 Add button to disconnect wallet (#4)
Part of https://www.notion.so/Add-disconnect-wallet-button-188a6b22d47280db91d5d180c7e151ae

Reviewed-on: deep-stack/sol-mem-gen#4
Co-authored-by: Nabarun <nabarun@deepstacksoft.com>
Co-committed-by: Nabarun <nabarun@deepstacksoft.com>
2025-01-27 11:43:40 +00:00
60728920f3 Verify payment on chain before generating meme (#3)
Part of https://www.notion.so/Implement-payment-gating-188a6b22d472806a9267c2bda72925cc

Co-authored-by: Adw8 <adwaitgharpure@gmail.com>
Reviewed-on: deep-stack/sol-mem-gen#3
Co-authored-by: adwait <adwait@noreply.git.vdb.to>
Co-committed-by: adwait <adwait@noreply.git.vdb.to>
2025-01-27 11:42:35 +00:00
5277b9d4ca Add readme with setup steps (#2)
Part of https://www.notion.so/Debug-and-fix-meme-generator-app-188a6b22d4728043ba2fd706d7c7def7

Reviewed-on: deep-stack/sol-mem-gen#2
Co-authored-by: Nabarun <nabarun@deepstacksoft.com>
Co-committed-by: Nabarun <nabarun@deepstacksoft.com>
2025-01-27 10:11:57 +00:00
76eb0a994c Add check for phantom wallet before throwing error (#1)
Part of https://www.notion.so/Debug-and-fix-meme-generator-app-188a6b22d4728043ba2fd706d7c7def7

- During meme generation, only solflare wallet was being checked for. This caused an error if the user was using phantom wallet
- Added a check for phantom wallet before throwing error

Co-authored-by: Adw8 <adwaitgharpure@gmail.com>
Reviewed-on: deep-stack/sol-mem-gen#1
Co-authored-by: adwait <adwait@noreply.git.vdb.to>
Co-committed-by: adwait <adwait@noreply.git.vdb.to>
2025-01-27 09:46:40 +00:00
45 changed files with 10801 additions and 809 deletions

30
.env.example Normal file
View 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
View 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
View File

@ -0,0 +1,2 @@
[provider]
cluster = "mainnet"

79
README.md Normal file
View 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`

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View 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
View 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 });
// }
// }

View 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 });
}
}

View 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 }
)
}
}

View 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>
);
}

View File

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

View 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>
);
}

View File

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

View 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

View File

@ -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
View 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
View 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
View 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
View 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");
}

View 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];
}
}

View 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;
}
}

View File

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

View File

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

View File

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

View 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
View 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(/&#39;/g, "'").replace(/&quot;/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,
};
};

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

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

File diff suppressed because it is too large Load Diff

3149
target/types/locker.ts Normal file

File diff suppressed because it is too large Load Diff

View File

@ -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
View 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"]
}