feat: add CoopCredits Solana media rewards ecosystem
Add complete token system with Plex/Tautulli/Overseer integration: - Anchor program for SPL token mint/burn/transfer - Express backend with OAuth, webhooks, Solana integration - Next.js frontend with dashboard, admin panel, wallet management - Docker deployment for 172.20.1.0/24 infrastructure - Production configs with SSL, Nginx, health monitoring Tautulli webhooks auto-mint on watch events. Overseer integration burns for content requests.
This commit is contained in:
@@ -0,0 +1,27 @@
|
||||
[features]
|
||||
seeds = false
|
||||
skip-lint = false
|
||||
|
||||
[programs.localnet]
|
||||
coop_credits = "CoopCreDits1111111111111111111111111111111"
|
||||
|
||||
[programs.devnet]
|
||||
coop_credits = "CoopCreDits1111111111111111111111111111111"
|
||||
|
||||
[registry]
|
||||
url = "https://api.apr.dev"
|
||||
|
||||
[provider]
|
||||
cluster = "devnet"
|
||||
wallet = "~/.config/solana/id.json"
|
||||
|
||||
[scripts]
|
||||
test = "yarn run ts-mocha -p ./tsconfig.json -t 1000000 tests/**/*.ts"
|
||||
|
||||
[test]
|
||||
startup_wait = 10000
|
||||
|
||||
[test.validator]
|
||||
bind_address = "0.0.0.0"
|
||||
url = "https://api.devnet.solana.com"
|
||||
ledger = ".anchor/test-ledger"
|
||||
Generated
+3596
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,14 @@
|
||||
[workspace]
|
||||
members = [
|
||||
"programs/*"
|
||||
]
|
||||
resolver = "2"
|
||||
|
||||
[profile.release]
|
||||
overflow-checks = true
|
||||
lto = "fat"
|
||||
codegen-units = 1
|
||||
[profile.release.build-override]
|
||||
opt-level = 3
|
||||
incremental = false
|
||||
codegen-units = 1
|
||||
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"name": "coop-credits-anchor",
|
||||
"version": "1.0.0",
|
||||
"description": "CoopCredits SPL Token Program",
|
||||
"scripts": {
|
||||
"lint:fix": "prettier */*.js \"*/**/*{.js,.ts}\" -w",
|
||||
"lint": "prettier */*.js \"*/**/*{.js,.ts}\" --check",
|
||||
"build": "anchor build",
|
||||
"deploy": "anchor deploy",
|
||||
"test": "anchor test",
|
||||
"idl:export": "anchor idl init --filepath target/idl/coop_credits.json CoopCreDits1111111111111111111111111111111"
|
||||
},
|
||||
"dependencies": {
|
||||
"@coral-xyz/anchor": "^0.29.0",
|
||||
"@solana/spl-token": "^0.3.9",
|
||||
"@solana/web3.js": "^1.87.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bn.js": "^5.1.0",
|
||||
"@types/chai": "^4.3.0",
|
||||
"@types/mocha": "^9.0.0",
|
||||
"chai": "^4.3.4",
|
||||
"mocha": "^9.0.3",
|
||||
"prettier": "^2.6.2",
|
||||
"ts-mocha": "^10.0.0",
|
||||
"typescript": "^4.3.5"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
[package]
|
||||
name = "coop-credits"
|
||||
version = "1.0.0"
|
||||
description = "CoopCredits SPL Token Program"
|
||||
edition = "2021"
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib", "lib"]
|
||||
name = "coop_credits"
|
||||
|
||||
[features]
|
||||
no-entrypoint = []
|
||||
no-idl = []
|
||||
no-log-ix-name = []
|
||||
cpi = ["no-entrypoint"]
|
||||
default = []
|
||||
|
||||
[dependencies]
|
||||
anchor-lang = { version = "0.29.0", features = ["init-if-needed"] }
|
||||
anchor-spl = { version = "0.29.0", features = ["metadata"] }
|
||||
solana-program = "=1.16.20"
|
||||
@@ -0,0 +1,251 @@
|
||||
use anchor_lang::prelude::*;
|
||||
use anchor_spl::token::{self, Burn, Mint, MintTo, Token, TokenAccount};
|
||||
|
||||
declare_id!("CoopCredits111111111111111111111111111111111");
|
||||
|
||||
pub const DECIMALS: u8 = 6;
|
||||
|
||||
#[program]
|
||||
pub mod coop_credits {
|
||||
use super::*;
|
||||
|
||||
pub fn initialize(ctx: Context<Initialize>) -> Result<()> {
|
||||
let state = &mut ctx.accounts.global_state;
|
||||
state.authority = ctx.accounts.authority.key();
|
||||
state.mint = ctx.accounts.mint.key();
|
||||
state.total_minted = 0;
|
||||
state.total_burned = 0;
|
||||
state.paused = false;
|
||||
state.rate = 10;
|
||||
state.bump = ctx.bumps.global_state;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn mint_to_user(
|
||||
ctx: Context<MintToUser>,
|
||||
amount: u64,
|
||||
session_id: String,
|
||||
title: String,
|
||||
minutes: u32,
|
||||
) -> Result<()> {
|
||||
require!(!ctx.accounts.state.paused, ErrorCode::Paused);
|
||||
require!(
|
||||
ctx.accounts.authority.key() == ctx.accounts.state.authority,
|
||||
ErrorCode::Unauthorized
|
||||
);
|
||||
|
||||
let seeds = &[b"mint_auth", &[ctx.bumps.mint_auth]];
|
||||
let signer = &[&seeds[..]];
|
||||
|
||||
let cpi = MintTo {
|
||||
mint: ctx.accounts.mint.to_account_info(),
|
||||
to: ctx.accounts.user_token.to_account_info(),
|
||||
authority: ctx.accounts.mint_auth.to_account_info(),
|
||||
};
|
||||
|
||||
token::mint_to(
|
||||
CpiContext::new_with_signer(ctx.accounts.token_prog.to_account_info(), cpi, signer),
|
||||
amount,
|
||||
)?;
|
||||
|
||||
let state = &mut ctx.accounts.state;
|
||||
state.total_minted = state.total_minted.saturating_add(amount);
|
||||
|
||||
let tx = &mut ctx.accounts.tx_record;
|
||||
tx.user = ctx.accounts.user_token.owner;
|
||||
tx.tx_type = TxType::Earn;
|
||||
tx.amount = amount;
|
||||
tx.timestamp = Clock::get()?.unix_timestamp;
|
||||
tx.session_id = session_id;
|
||||
tx.title = title;
|
||||
tx.minutes = minutes;
|
||||
|
||||
msg!("Minted {}", amount);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn burn_from_user(
|
||||
ctx: Context<BurnFromUser>,
|
||||
amount: u64,
|
||||
request_id: String,
|
||||
title: String,
|
||||
) -> Result<()> {
|
||||
require!(!ctx.accounts.state.paused, ErrorCode::Paused);
|
||||
|
||||
let cpi = Burn {
|
||||
mint: ctx.accounts.mint.to_account_info(),
|
||||
from: ctx.accounts.user_token.to_account_info(),
|
||||
authority: ctx.accounts.user.to_account_info(),
|
||||
};
|
||||
|
||||
token::burn(
|
||||
CpiContext::new(ctx.accounts.token_prog.to_account_info(), cpi),
|
||||
amount,
|
||||
)?;
|
||||
|
||||
let state = &mut ctx.accounts.state;
|
||||
state.total_burned = state.total_burned.saturating_add(amount);
|
||||
|
||||
let tx = &mut ctx.accounts.tx_record;
|
||||
tx.user = ctx.accounts.user.key();
|
||||
tx.tx_type = TxType::Spend;
|
||||
tx.amount = amount;
|
||||
tx.timestamp = Clock::get()?.unix_timestamp;
|
||||
tx.session_id = request_id;
|
||||
tx.title = title;
|
||||
tx.minutes = 0;
|
||||
|
||||
msg!("Burned {}", amount);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn set_rate(ctx: Context<Admin>, new_rate: u32) -> Result<()> {
|
||||
require!(
|
||||
ctx.accounts.authority.key() == ctx.accounts.state.authority,
|
||||
ErrorCode::Unauthorized
|
||||
);
|
||||
ctx.accounts.state.rate = new_rate;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn pause(ctx: Context<Admin>) -> Result<()> {
|
||||
require!(
|
||||
ctx.accounts.authority.key() == ctx.accounts.state.authority,
|
||||
ErrorCode::Unauthorized
|
||||
);
|
||||
ctx.accounts.state.paused = true;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn resume(ctx: Context<Admin>) -> Result<()> {
|
||||
require!(
|
||||
ctx.accounts.authority.key() == ctx.accounts.state.authority,
|
||||
ErrorCode::Unauthorized
|
||||
);
|
||||
ctx.accounts.state.paused = false;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Accounts)]
|
||||
pub struct Initialize<'info> {
|
||||
#[account(init, payer = authority, space = 8 + State::SIZE, seeds = [b"state"], bump)]
|
||||
pub global_state: Account<'info, State>,
|
||||
|
||||
#[account(init, payer = authority, mint::decimals = DECIMALS, mint::authority = mint_auth)]
|
||||
pub mint: Account<'info, Mint>,
|
||||
|
||||
/// CHECK: PDA
|
||||
#[account(seeds = [b"mint_auth"], bump)]
|
||||
pub mint_auth: UncheckedAccount<'info>,
|
||||
|
||||
#[account(mut)]
|
||||
pub authority: Signer<'info>,
|
||||
|
||||
pub system_program: Program<'info, System>,
|
||||
pub token_prog: Program<'info, Token>,
|
||||
pub rent: Sysvar<'info, Rent>,
|
||||
}
|
||||
|
||||
#[derive(Accounts)]
|
||||
#[instruction(amount: u64, session_id: String, title: String, minutes: u32)]
|
||||
pub struct MintToUser<'info> {
|
||||
#[account(mut, seeds = [b"state"], bump = state.bump)]
|
||||
pub state: Account<'info, State>,
|
||||
|
||||
#[account(mut)]
|
||||
pub mint: Account<'info, Mint>,
|
||||
|
||||
/// CHECK: PDA
|
||||
#[account(seeds = [b"mint_auth"], bump)]
|
||||
pub mint_auth: UncheckedAccount<'info>,
|
||||
|
||||
#[account(mut)]
|
||||
pub user_token: Account<'info, TokenAccount>,
|
||||
|
||||
#[account(init, payer = authority, space = 8 + TxRecord::SIZE, seeds = [b"tx", user_token.owner.as_ref(), &Clock::get()?.unix_timestamp.to_le_bytes()], bump)]
|
||||
pub tx_record: Account<'info, TxRecord>,
|
||||
|
||||
#[account(mut)]
|
||||
pub authority: Signer<'info>,
|
||||
|
||||
pub system_program: Program<'info, System>,
|
||||
pub token_prog: Program<'info, Token>,
|
||||
}
|
||||
|
||||
#[derive(Accounts)]
|
||||
#[instruction(amount: u64, request_id: String, title: String)]
|
||||
pub struct BurnFromUser<'info> {
|
||||
#[account(mut, seeds = [b"state"], bump = state.bump)]
|
||||
pub state: Account<'info, State>,
|
||||
|
||||
#[account(mut)]
|
||||
pub mint: Account<'info, Mint>,
|
||||
|
||||
#[account(mut)]
|
||||
pub user_token: Account<'info, TokenAccount>,
|
||||
|
||||
#[account(init, payer = authority, space = 8 + TxRecord::SIZE, seeds = [b"tx", user.key().as_ref(), &Clock::get()?.unix_timestamp.to_le_bytes()], bump)]
|
||||
pub tx_record: Account<'info, TxRecord>,
|
||||
|
||||
#[account(mut)]
|
||||
pub authority: Signer<'info>,
|
||||
|
||||
/// CHECK: User signer
|
||||
#[account(mut)]
|
||||
pub user: Signer<'info>,
|
||||
|
||||
pub system_program: Program<'info, System>,
|
||||
pub token_prog: Program<'info, Token>,
|
||||
}
|
||||
|
||||
#[derive(Accounts)]
|
||||
pub struct Admin<'info> {
|
||||
#[account(mut, seeds = [b"state"], bump = state.bump)]
|
||||
pub state: Account<'info, State>,
|
||||
pub authority: Signer<'info>,
|
||||
}
|
||||
|
||||
#[account]
|
||||
pub struct State {
|
||||
pub authority: Pubkey,
|
||||
pub mint: Pubkey,
|
||||
pub total_minted: u64,
|
||||
pub total_burned: u64,
|
||||
pub paused: bool,
|
||||
pub rate: u32,
|
||||
pub bump: u8,
|
||||
}
|
||||
|
||||
impl State {
|
||||
pub const SIZE: usize = 32 + 32 + 8 + 8 + 1 + 4 + 1;
|
||||
}
|
||||
|
||||
#[account]
|
||||
pub struct TxRecord {
|
||||
pub user: Pubkey,
|
||||
pub tx_type: TxType,
|
||||
pub amount: u64,
|
||||
pub timestamp: i64,
|
||||
pub session_id: String,
|
||||
pub title: String,
|
||||
pub minutes: u32,
|
||||
}
|
||||
|
||||
impl TxRecord {
|
||||
pub const SIZE: usize = 32 + 1 + 8 + 8 + 68 + 132 + 4;
|
||||
}
|
||||
|
||||
#[derive(AnchorSerialize, AnchorDeserialize, Clone, Copy, PartialEq)]
|
||||
pub enum TxType {
|
||||
Earn,
|
||||
Spend,
|
||||
}
|
||||
|
||||
#[error_code]
|
||||
pub enum ErrorCode {
|
||||
#[msg("Paused")]
|
||||
Paused,
|
||||
#[msg("Unauthorized")]
|
||||
Unauthorized,
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import * as anchor from "@coral-xyz/anchor";
|
||||
import { Program } from "@coral-xyz/anchor";
|
||||
import { CoopCredits } from "../target/types/coop_credits";
|
||||
import { expect } from "chai";
|
||||
|
||||
describe("coop-credits", () => {
|
||||
// Configure the client to use the local cluster.
|
||||
anchor.setProvider(anchor.AnchorProvider.env());
|
||||
|
||||
const program = anchor.workspace.CoopCredits as Program<CoopCredits>;
|
||||
const provider = anchor.getProvider();
|
||||
|
||||
let globalStatePDA: anchor.web3.PublicKey;
|
||||
let mintPDA: anchor.web3.PublicKey;
|
||||
let mintAuthorityPDA: anchor.web3.PublicKey;
|
||||
|
||||
before(async () => {
|
||||
// Find PDAs
|
||||
[globalStatePDA] = anchor.web3.PublicKey.findProgramAddressSync(
|
||||
[Buffer.from("global_state")],
|
||||
program.programId,
|
||||
);
|
||||
|
||||
[mintAuthorityPDA] = anchor.web3.PublicKey.findProgramAddressSync(
|
||||
[Buffer.from("mint_authority")],
|
||||
program.programId,
|
||||
);
|
||||
});
|
||||
|
||||
it("Initializes the program", async () => {
|
||||
const tx = await program.methods
|
||||
.initialize()
|
||||
.accounts({
|
||||
globalState: globalStatePDA,
|
||||
authority: provider.publicKey,
|
||||
systemProgram: anchor.web3.SystemProgram.programId,
|
||||
tokenProgram: anchor.utils.token.TOKEN_PROGRAM_ID,
|
||||
rent: anchor.web3.SYSVAR_RENT_PUBKEY,
|
||||
})
|
||||
.rpc();
|
||||
|
||||
console.log("Initialize transaction signature", tx);
|
||||
|
||||
// Verify global state
|
||||
const globalState = await program.account.globalState.fetch(globalStatePDA);
|
||||
expect(globalState.authority.toString()).to.equal(
|
||||
provider.publicKey.toString(),
|
||||
);
|
||||
expect(globalState.paused).to.be.false;
|
||||
expect(globalState.creditsPerMinute).to.equal(10);
|
||||
});
|
||||
|
||||
it("Updates minting rate", async () => {
|
||||
const newRate = 20;
|
||||
|
||||
const tx = await program.methods
|
||||
.updateMintingRate(newRate)
|
||||
.accounts({
|
||||
globalState: globalStatePDA,
|
||||
authority: provider.publicKey,
|
||||
})
|
||||
.rpc();
|
||||
|
||||
console.log("Update rate transaction signature", tx);
|
||||
|
||||
const globalState = await program.account.globalState.fetch(globalStatePDA);
|
||||
expect(globalState.creditsPerMinute).to.equal(newRate);
|
||||
});
|
||||
|
||||
it("Pauses and resumes minting", async () => {
|
||||
// Pause
|
||||
let tx = await program.methods
|
||||
.pauseMinting()
|
||||
.accounts({
|
||||
globalState: globalStatePDA,
|
||||
authority: provider.publicKey,
|
||||
})
|
||||
.rpc();
|
||||
|
||||
let globalState = await program.account.globalState.fetch(globalStatePDA);
|
||||
expect(globalState.paused).to.be.true;
|
||||
|
||||
// Resume
|
||||
tx = await program.methods
|
||||
.resumeMinting()
|
||||
.accounts({
|
||||
globalState: globalStatePDA,
|
||||
authority: provider.publicKey,
|
||||
})
|
||||
.rpc();
|
||||
|
||||
globalState = await program.account.globalState.fetch(globalStatePDA);
|
||||
expect(globalState.paused).to.be.false;
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user