Create WSOL lock for every fourth verified tweet #12

Merged
nabarun merged 16 commits from as-api-create-lock into main 2025-02-06 12:48:53 +00:00
23 changed files with 7039 additions and 79 deletions

View File

@ -1,11 +1,11 @@
# Get key from https://fal.ai
FAL_AI_KEY=
NEXT_PUBLIC_MTM_TOKEN_MINT=97RggLo3zV5kFGYW4yoQTxr4Xkz4Vg2WPHzNYXXWpump
NEXT_PUBLIC_PAYMENT_RECEIVER_ADDRESS=FFDx3SdAEeXrp6BTmStB4BDHpctGsaasZq4FFcowRobY
NEXT_PUBLIC_SOLANA_RPC_URL=https://young-radial-orb.solana-mainnet.quiknode.pro/67612b364664616c29514e551bf5de38447ca3d4
NEXT_PUBLIC_SOLANA_WEBSOCKET_URL=wss://young-radial-orb.solana-mainnet.quiknode.pro/67612b364664616c29514e551bf5de38447ca3d4
NEXT_PUBLIC_USDC_MINT=EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v
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=
@ -16,4 +16,11 @@ PINATA_GATEWAY=
# Change to your website URL
# For development: SITE_URL=http://localhost:3000
SITE_URL=https://memes.markto.market
NEXT_PUBLIC_ACCOUNT_HANDLE=
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

2
Anchor.toml Normal file
View File

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

View File

@ -52,7 +52,7 @@ This project is a Solana-based meme generator that allows users to connect their
PINATA_GATEWAY=
# Add the account handle to be set in the tweet
NEXT_PUBLIC_ACCOUNT_HANDLE=
NEXT_PUBLIC_TWITTER_HANDLE=
```
- Run the development server:

1
next-env.d.ts vendored
View File

@ -1,6 +1,5 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
/// <reference types="next/navigation-types/compat/navigation" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/basic-features/typescript for more information.

162
package-lock.json generated
View File

@ -8,6 +8,7 @@
"name": "solana-meme-generator",
"version": "0.1.0",
"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",
@ -24,7 +25,9 @@
"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",
@ -73,6 +76,64 @@
"@chainsafe/is-ip": "^2.0.1"
}
},
"node_modules/@coral-xyz/anchor": {
"version": "0.30.1",
"resolved": "https://registry.npmjs.org/@coral-xyz/anchor/-/anchor-0.30.1.tgz",
"integrity": "sha512-gDXFoF5oHgpriXAaLpxyWBHdCs8Awgf/gLHIo6crv7Aqm937CNdY+x+6hoj7QR5vaJV7MxWSQ0NGFzL3kPbWEQ==",
"dependencies": {
"@coral-xyz/anchor-errors": "^0.30.1",
"@coral-xyz/borsh": "^0.30.1",
"@noble/hashes": "^1.3.1",
"@solana/web3.js": "^1.68.0",
"bn.js": "^5.1.2",
"bs58": "^4.0.1",
"buffer-layout": "^1.2.2",
"camelcase": "^6.3.0",
"cross-fetch": "^3.1.5",
"crypto-hash": "^1.3.0",
"eventemitter3": "^4.0.7",
"pako": "^2.0.3",
"snake-case": "^3.0.4",
"superstruct": "^0.15.4",
"toml": "^3.0.0"
},
"engines": {
"node": ">=11"
}
},
"node_modules/@coral-xyz/anchor-errors": {
"version": "0.30.1",
"resolved": "https://registry.npmjs.org/@coral-xyz/anchor-errors/-/anchor-errors-0.30.1.tgz",
"integrity": "sha512-9Mkradf5yS5xiLWrl9WrpjqOrAV+/W2RQHDlbnAZBivoGpOs1ECjoDCkVk4aRG8ZdiFiB8zQEVlxf+8fKkmSfQ==",
"engines": {
"node": ">=10"
}
},
"node_modules/@coral-xyz/anchor/node_modules/eventemitter3": {
"version": "4.0.7",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz",
"integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw=="
},
"node_modules/@coral-xyz/anchor/node_modules/superstruct": {
"version": "0.15.5",
"resolved": "https://registry.npmjs.org/superstruct/-/superstruct-0.15.5.tgz",
"integrity": "sha512-4AOeU+P5UuE/4nOUkmcQdW5y7i9ndt1cQd/3iUe+LTz3RxESf/W/5lg4B74HbDMMv8PHnPnGCQFH45kBcrQYoQ=="
},
"node_modules/@coral-xyz/borsh": {
"version": "0.30.1",
"resolved": "https://registry.npmjs.org/@coral-xyz/borsh/-/borsh-0.30.1.tgz",
"integrity": "sha512-aaxswpPrCFKl8vZTbxLssA2RvwX2zmKLlRCIktJOwW+VpVwYtXRtlWiIP+c2pPRKneiTiWCN2GEMSH9j1zTlWQ==",
"dependencies": {
"bn.js": "^5.1.2",
"buffer-layout": "^1.2.0"
},
"engines": {
"node": ">=10"
},
"peerDependencies": {
"@solana/web3.js": "^1.68.0"
}
},
"node_modules/@cspotcode/source-map-support": {
"version": "0.8.1",
"resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz",
@ -878,6 +939,13 @@
"integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==",
"devOptional": true
},
"node_modules/@types/big.js": {
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/@types/big.js/-/big.js-6.2.2.tgz",
"integrity": "sha512-e2cOW9YlVzFY2iScnGBBkplKsrn2CsObHQ2Hiw4V1sSyiGbgWL8IyqE3zFi1Pt5o1pdAtYkDAIsF3KKUPjdzaA==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/bn.js": {
"version": "5.1.6",
"resolved": "https://registry.npmjs.org/@types/bn.js/-/bn.js-5.1.6.tgz",
@ -887,6 +955,17 @@
"@types/node": "*"
}
},
"node_modules/@types/bs58": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/@types/bs58/-/bs58-4.0.4.tgz",
"integrity": "sha512-0IEpMFXXQi2zXaXl9GJ3sRwQo0uEkD+yFOv+FnAU5lkPtcu6h61xb7jc2CFPEZ5BUOaiP13ThuGc9HD4R8lR5g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*",
"base-x": "^3.0.6"
}
},
"node_modules/@types/connect": {
"version": "3.4.38",
"resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz",
@ -1763,6 +1842,14 @@
"ieee754": "^1.2.1"
}
},
"node_modules/buffer-layout": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/buffer-layout/-/buffer-layout-1.2.2.tgz",
"integrity": "sha512-kWSuLN694+KTk8SrYvCqwP2WcgQjoRCiF5b4QDvkkz8EmgD+aWAIceGFKMIAdmF/pH+vpgNV3d3kAKorcdAmWA==",
"engines": {
"node": ">=4.5"
}
},
"node_modules/bufferutil": {
"version": "4.0.9",
"resolved": "https://registry.npmjs.org/bufferutil/-/bufferutil-4.0.9.tgz",
@ -1872,6 +1959,17 @@
"node": ">=6"
}
},
"node_modules/camelcase": {
"version": "6.3.0",
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz",
"integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==",
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/camelcase-css": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz",
@ -2125,6 +2223,14 @@
"integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==",
"devOptional": true
},
"node_modules/cross-fetch": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-3.2.0.tgz",
"integrity": "sha512-Q+xVJLoGOeIMXZmbUK4HYk+69cQH6LudR0Vu/pRm2YlU/hDV9CiS0gKUMaWY5f2NeUH9C1nV3bsTlCo0FsTV1Q==",
"dependencies": {
"node-fetch": "^2.7.0"
}
},
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@ -2138,6 +2244,17 @@
"node": ">= 8"
}
},
"node_modules/crypto-hash": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/crypto-hash/-/crypto-hash-1.3.0.tgz",
"integrity": "sha512-lyAZ0EMyjDkVvz8WOeVnuCPvKVBXcMv1l5SVqO1yC7PzTwrD/pPje/BIRbWhMoPe436U+Y2nD7f5bFx0kt+Sbg==",
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/cssesc": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
@ -2393,6 +2510,15 @@
"node": ">=6.0.0"
}
},
"node_modules/dot-case": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/dot-case/-/dot-case-3.0.4.tgz",
"integrity": "sha512-Kv5nKlh6yRrdrGvxeJ2e5y2eRUpkUosIW4A2AS38zwSz27zu7ufDwQPi5Jhs3XAlGNetl3bmnGhQsMtkKJnj3w==",
"dependencies": {
"no-case": "^3.0.4",
"tslib": "^2.0.3"
}
},
"node_modules/dotenv": {
"version": "16.4.7",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.7.tgz",
@ -4714,6 +4840,14 @@
"loose-envify": "cli.js"
}
},
"node_modules/lower-case": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/lower-case/-/lower-case-2.0.2.tgz",
"integrity": "sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg==",
"dependencies": {
"tslib": "^2.0.3"
}
},
"node_modules/lru-cache": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
@ -5073,6 +5207,15 @@
"node": "^10 || ^12 || >=14"
}
},
"node_modules/no-case": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/no-case/-/no-case-3.0.4.tgz",
"integrity": "sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg==",
"dependencies": {
"lower-case": "^2.0.2",
"tslib": "^2.0.3"
}
},
"node_modules/node-abi": {
"version": "3.74.0",
"resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.74.0.tgz",
@ -5498,6 +5641,11 @@
"resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz",
"integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw=="
},
"node_modules/pako": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/pako/-/pako-2.1.0.tgz",
"integrity": "sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug=="
},
"node_modules/parent-module": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
@ -6551,6 +6699,15 @@
"npm": ">= 3.0.0"
}
},
"node_modules/snake-case": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/snake-case/-/snake-case-3.0.4.tgz",
"integrity": "sha512-LAOh4z89bGQvl9pFfNF8V146i7o7/CqFPbqzYgP+yYzDIDeS9HaNFtXABamRW+AQzEVODcvE79ljJ+8a9YSdMg==",
"dependencies": {
"dot-case": "^3.0.4",
"tslib": "^2.0.3"
}
},
"node_modules/socks": {
"version": "2.8.3",
"resolved": "https://registry.npmjs.org/socks/-/socks-2.8.3.tgz",
@ -7112,6 +7269,11 @@
"node": ">=8.0"
}
},
"node_modules/toml": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/toml/-/toml-3.0.0.tgz",
"integrity": "sha512-y/mWCZinnvxjTKYhJ+pYxwD0mRLVvOtdS2Awbgxln6iEnt4rk0yBxeSBHkGJcPucRiG0e55mwWp+g/05rsrd6w=="
},
"node_modules/tr46": {
"version": "0.0.3",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",

View File

@ -9,9 +9,10 @@
"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",
@ -25,7 +26,9 @@
"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",

View File

@ -3,11 +3,11 @@ import BN from "bn.js";
import fetch from 'node-fetch';
import Big from 'big.js';
assert(process.env.NEXT_PUBLIC_USDC_MINT, 'USDC_MINT is required');
assert(process.env.NEXT_PUBLIC_MTM_TOKEN_MINT, 'MTM_TOKEN_MINT is required');
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_TOKEN_MINT;
const USDC_MINT = process.env.NEXT_PUBLIC_USDC_MINT;
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

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

@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from 'next/server';
import { saveTweet, verifySignatureInTweet } from '../../../utils/verifyTweet';
import { processTweet, verifySignatureInTweet } from '../../../utils/verifyTweet';
import { extractData } from '../../../utils/tweetMessage';
export async function POST(req: NextRequest): Promise<NextResponse> {
try {
@ -21,7 +22,7 @@ export async function POST(req: NextRequest): Promise<NextResponse> {
}
const isSigVerified = await verifySignatureInTweet(txSignature);
const isHandleCorrect = handle === process.env.NEXT_PUBLIC_ACCOUNT_HANDLE;
const isHandleCorrect = handle === process.env.NEXT_PUBLIC_TWITTER_HANDLE;
// TODO: Verify dynamic page URL in tweet
const isVerified = isSigVerified && isHandleCorrect;
@ -29,12 +30,10 @@ export async function POST(req: NextRequest): Promise<NextResponse> {
throw new Error('Tweet is not valid');
}
const { isFourthUser } = await saveTweet({ transactionSignature: txSignature, url: memeUrl });
if (isFourthUser) {
createTokenLockForRecipient();
}
// Verify and store valid tweet
const result = await processTweet(txSignature, memeUrl);
return NextResponse.json({ success: isVerified, message: 'Tweet verified' })
return NextResponse.json(result)
} catch (error) {
console.error('Error while verifying tweet:', error)
return NextResponse.json(
@ -43,24 +42,3 @@ export async function POST(req: NextRequest): Promise<NextResponse> {
)
}
}
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,
};
};
// TODO: Implement function to create lock for a recipient
const createTokenLockForRecipient = () => {
console.log('Lock created');
}

View File

@ -1,6 +1,7 @@
'use client'
import React, { useState, useEffect } from 'react'
import Big from 'big.js';
import BN from 'bn.js';
import WalletHeader from '../components/WalletHeader'
@ -61,6 +62,18 @@ const Page: React.FC = (): React.ReactElement => {
}
}
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> => {
const { connected, publicKey, type } = walletState;
@ -75,7 +88,7 @@ const Page: React.FC = (): React.ReactElement => {
// Convert cost in USDC to MTM tokens using the price ratio
const paymentResult = await processMTMPayment(
publicKey,
cost,
roundUpBigNumber(cost),
type
)

View File

@ -5,6 +5,8 @@ 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
@ -21,11 +23,11 @@ interface GenerationState {
error: string | null
}
const baseUnitToDecimalFormat = (value: BN, decimals: number): string => {
const baseUnitToWholeNumber = (value: BN, decimals: number): string => {
const bigValue = new Big(value.toString());
const factor = new Big(10).pow(decimals);
return bigValue.div(factor).toFixed(decimals);
return bigValue.div(factor).round(0, Big.roundUp).toFixed(0);
}
const AIServiceCard: React.FC<AIServiceCardProps> = ({
@ -93,11 +95,11 @@ const AIServiceCard: React.FC<AIServiceCardProps> = ({
const generateTwitterShareUrl = (imageUrl: string, transactionSignature: string): string => {
const baseUrl = window.location.href;
const cid = imageUrl.split("/image/")[1];
const memeUrl = `${baseUrl}memes/${cid}`;
const memePageUrl = `${baseUrl}memes/${cid}`;
const tweetText = `Check out this meme that I generated! \n TX Hash: '${transactionSignature}' \n @${process.env.NEXT_PUBLIC_ACCOUNT_HANDLE} \n`;
const tweetText = generateTweetText(transactionSignature, process.env.NEXT_PUBLIC_TWITTER_HANDLE)
return `https://twitter.com/intent/tweet?text=${encodeURIComponent(tweetText)}&url=${encodeURIComponent(memeUrl)}`;
return `https://twitter.com/intent/tweet?text=${encodeURIComponent(tweetText)}&url=${encodeURIComponent(memePageUrl)}`;
};
return (
@ -109,7 +111,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: {priceMTM ? baseUnitToDecimalFormat(priceMTM, 6) : '...'} MTM
Cost: {priceMTM ? baseUnitToWholeNumber(priceMTM, 6) : '...'} MTM
</div>
</div>
@ -133,7 +135,7 @@ const AIServiceCard: React.FC<AIServiceCardProps> = ({
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 ${priceMTM ? baseUnitToDecimalFormat(priceMTM, 6) : '...'} MTM & Generate`}
{generationState.loading ? 'Processing...' : `Pay ${priceMTM ? baseUnitToWholeNumber(priceMTM, 6) : '...'} MTM & Generate`}
</button>
</div>

View File

@ -10,4 +10,10 @@ export class Tweet {
@Column({ unique: true })
transactionSignature!: string;
@Column({ type: 'boolean', nullable: true })
isLockCreated!: boolean | null;
@Column({ type: 'text', unique: true, nullable: true })
lockEscrow!: string | null;
}

196
src/locker-utils/index.ts Normal file
View File

@ -0,0 +1,196 @@
/**
* 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);
const connection = new Connection(process.env.NEXT_PUBLIC_SOLANA_RPC_URL);
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);
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,
// TODO: Fix type error for escrowToken
} as any)
.remainingAccounts(remainingAccounts ? remainingAccounts : [])
.preInstructions([
createAssociatedTokenAccountInstruction(
ownerKeypair.publicKey,
escrowToken,
escrow,
tokenMint,
tokenProgram,
ASSOCIATED_TOKEN_PROGRAM_ID
),
])
.signers([baseKP, ownerKeypair])
.rpc();
return escrow;
} catch (error) {
if (error instanceof TransactionExpiredTimeoutError) {
console.error('Transaction confirmation delayed for', error.signature);
console.log('Confirming the transaction again...');
const confirmedTransaction = await connection.getTransaction(error.signature, {
commitment: 'confirmed',
maxSupportedTransactionVersion: 0
});
if(confirmedTransaction === null) {
console.error('Transaction failed for', error.signature);
throw error;
}
return escrow;
}
throw error;
}
}

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

@ -12,11 +12,11 @@ import {
import { WalletType } from './types'
assert(process.env.NEXT_PUBLIC_SOLANA_RPC_URL, 'SOLANA_RPC_URL is required');
assert(process.env.NEXT_PUBLIC_MTM_TOKEN_MINT, 'MTM_TOKEN_MINT is required');
assert(process.env.NEXT_PUBLIC_PAYMENT_RECEIVER_ADDRESS, 'PAYMENT_RECEIVER_ADDRESS 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_TOKEN_MINT;
const PAYMENT_RECEIVER_ADDRESS = process.env.NEXT_PUBLIC_PAYMENT_RECEIVER_ADDRESS;
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;

106
src/utils/create-lock.ts Normal file
View File

@ -0,0 +1,106 @@
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, balance: BN): Promise<anchor.web3.PublicKey | undefined> {
if (balance.eq(new BN(0))) {
console.log('No balance available to create lock, skipping...');
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: balance, // 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 extractInfo(transactionSignature: string) {
const transaction = await connection.getParsedTransaction(transactionSignature, 'confirmed');
if (!transaction) {
throw new Error('Transaction not found');
}
const transferInstruction = transaction.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 };
}
export async function createRewardLock(authority: string, amount: 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');
}
const duration = 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(authority);
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(amount).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, duration, 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

@ -3,8 +3,7 @@ import BN from 'bn.js';
import { Connection } from '@solana/web3.js';
import { TOKEN_PROGRAM_ID } from '@solana/spl-token';
import { Payment } from '../entity/Payment';
import { extractTxInfo } from './extractTxInfo';
assert(process.env.NEXT_PUBLIC_SOLANA_RPC_URL, 'SOLANA_RPC_URL is required');
@ -44,6 +43,7 @@ export async function markSignatureAsUsed(transactionSignature: string): Promise
});
}
// TODO: Verify that payment receiver is correct
export async function verifyPayment(
transactionSignature: string,
tokenAmount: BN,
@ -54,22 +54,7 @@ export async function verifyPayment(
return false;
}
const transaction = await connection.getParsedTransaction(transactionSignature, 'confirmed');
if (!transaction) {
throw new Error('Transaction not found');
}
const transferInstruction = transaction.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 { parsed } = transferInstruction;
const { info } = parsed;
const { amount } = info;
const { amount } = await extractTxInfo(transactionSignature);
const transactionAmount = new BN(amount);

View File

@ -1,4 +1,8 @@
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);
@ -18,14 +22,48 @@ export async function verifySignatureInTweet(transactionSignature: string): Prom
return true;
}
export async function saveTweet(data: Partial<Tweet>): Promise<{ isFourthUser: boolean }> {
return await global.appDataSource.transaction(async (transactionalEntityManager) => {
const tweetRepository = transactionalEntityManager.getRepository(global.entities.Tweet);
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(data);
return {
isFourthUser: tweet.id % 4 === 0
};
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.");
}
}

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