This commit is contained in:
Pascal Engélibert 2025-06-22 22:10:41 +02:00
commit 2bab960f23
9 changed files with 62500 additions and 911 deletions

2344
g1bridge/Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -6,13 +6,16 @@ edition = "2024"
[dependencies] [dependencies]
argp = "0.4.0" argp = "0.4.0"
codec = { package = "parity-scale-codec", version = "3.6.12" } codec = { package = "parity-scale-codec", version = "3.6.12" }
hex = "0.4.3"
log = "0.4.27" log = "0.4.27"
reqwest = { version = "0.11.27", features = ["json"] }
scrypt = { version = "0.11.0", default-features = false } scrypt = { version = "0.11.0", default-features = false }
serde = { version = "1.0.219", features = ["derive"] } serde = { version = "1.0.219", features = ["derive"] }
serde_json = "1.0.140"
simplelog = "0.12.2" simplelog = "0.12.2"
sp-core = { git = "https://github.com/duniter/duniter-polkadot-sdk.git", branch = "duniter-substrate-v1.11.0" } sp-core = { git = "https://github.com/duniter/duniter-polkadot-sdk.git", branch = "duniter-substrate-v1.14.0" }
sp-runtime = { git = "https://github.com/duniter/duniter-polkadot-sdk.git", branch = "duniter-substrate-v1.11.0" } sp-runtime = { git = "https://github.com/duniter/duniter-polkadot-sdk.git", branch = "duniter-substrate-v1.14.0" }
subxt = { git = 'https://github.com/duniter/subxt', branch = 'subxt-v0.35.3-duniter-substrate-v1.11.0', default-features = false, features = [ subxt = { git = 'https://github.com/duniter/subxt', branch = 'subxt-v0.37.0-duniter-substrate-v1.14.0', default-features = false, features = [
"substrate-compat", "substrate-compat",
"native", "native",
"jsonrpsee", "jsonrpsee",
@ -21,5 +24,10 @@ tiny_http = "0.12.0"
tokio = { version = "1.38.0", features = ["macros", "rt-multi-thread"] } tokio = { version = "1.38.0", features = ["macros", "rt-multi-thread"] }
typed-sled = "0.2.3" typed-sled = "0.2.3"
# some dependency forgot to specify revision of another git dep...
[patch.'https://github.com/w3f/fflonk']
# https://github.com/rust-lang/cargo/issues/5478
fflonk = { git = "https://github.com//w3f/fflonk.git", rev = "1e854f35e9a65d08b11a86291405cdc95baa0a35" }
[profile.release] [profile.release]
lto = "fat" lto = "fat"

View file

@ -0,0 +1,12 @@
query ReceivedTransactions($address: String!, $block: Int!) {
tx: transfer(where: { blockNumber: { _gt: 1 }, to: { id: { _eq: "" } } }) {
amount
comment {
remark
}
}
b: block(limit: 1, orderBy: { height: DESC }) {
height
hash
}
}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

51
g1bridge/src/indexer.rs Normal file
View file

@ -0,0 +1,51 @@
use serde::Deserialize;
/*
[
{
"data": {
"transfer": [
{
"amount": 42,
"comment": null
},
{
"amount": 30,
"comment": {"remark": "tuxmain"}
}
],
"block": [
{
"height": 7040132,
"hash": "\\xe669ff0d26dc9c48ec905916d39b0f11447cf63a37c0d1094f19cdfbbb199c67"
}
]
}
}
]
*/
#[derive(Debug, Deserialize)]
pub struct Comment {
pub remark: String,
}
#[derive(Debug, Deserialize)]
pub struct Transfer {
pub amount: u64,
pub comment: Option<Comment>,
}
#[derive(Debug, Deserialize)]
pub struct Block {
pub height: u64,
pub hash: String,
}
#[derive(Debug, Deserialize)]
pub struct Data {
pub transfer: Vec<Transfer>,
pub block: Vec<Block>,
}
#[derive(Debug, Deserialize)]
pub struct DataWrapper {
pub data: Data,
}
pub type Response = Vec<DataWrapper>;

View file

@ -1,22 +1,23 @@
#![feature(try_blocks)] #![feature(try_blocks)]
mod blockchain; mod blockchain;
mod indexer;
mod response;
use argp::FromArgs; use argp::FromArgs;
use serde::{Deserialize, Serialize, de}; use serde::{Deserialize, Serialize};
use sp_core::Pair as _; use sp_core::Pair as _;
use std::{ use std::{
io::{BufRead, Read}, collections::{hash_map::Entry, HashMap}, io::{BufRead, Read}, str::FromStr, sync::Arc
str::FromStr,
sync::Arc,
}; };
use subxt::{ use subxt::{
ext::sp_core::ed25519::{Pair, Public}, ext::sp_core::ed25519::Pair,
utils::{AccountId32, MultiAddress}, utils::AccountId32,
}; };
use tiny_http::{Response, ResponseBox, Server, ServerConfig}; use tiny_http::{Response, ResponseBox, Server};
use tokio::sync::mpsc::{Sender, channel}; use tokio::sync::mpsc::{Sender, channel};
use typed_sled::Tree; use typed_sled::Tree;
use log::error;
/// Game to Blockchain /// Game to Blockchain
const TREE_DEBT_G2B: &'static str = "debt_g2b"; const TREE_DEBT_G2B: &'static str = "debt_g2b";
@ -28,6 +29,10 @@ struct Opt {
#[argp(option, short = 'd')] #[argp(option, short = 'd')]
db: String, db: String,
/// Duniter indexer URL
#[argp(option, short = 'i', default = "String::from(\"https://squid.gdev.gyroi.de/v1/graphql\")")]
indexer: String,
/// secret key is legacy (two passwords on the two first lines) /// secret key is legacy (two passwords on the two first lines)
#[argp(switch, short = 'l')] #[argp(switch, short = 'l')]
legacy: bool, legacy: bool,
@ -80,12 +85,16 @@ async fn main() {
.expect("Cannot read secret key file"); .expect("Cannot read secret key file");
Pair::from_string(sk_content.trim(), None).expect("Cannot decode secret key") Pair::from_string(sk_content.trim(), None).expect("Cannot decode secret key")
}; };
let pk: AccountId32 = pair.public().into();
let pk_str = pk.to_string();
simplelog::SimpleLogger::init(log::LevelFilter::Debug, simplelog::Config::default()).unwrap(); simplelog::SimpleLogger::init(log::LevelFilter::Debug, simplelog::Config::default()).unwrap();
let db = typed_sled::open(opt.db).expect("Cannot open database"); let db = typed_sled::open(opt.db).expect("Cannot open database");
let tree_debt_g2b = Tree::<DebtG2B, ()>::open(&db, TREE_DEBT_G2B); let tree_debt_g2b = Tree::<DebtG2B, ()>::open(&db, TREE_DEBT_G2B);
let client = reqwest::Client::new();
let (bc_send, bc_recv) = channel(128); let (bc_send, bc_recv) = channel(128);
tokio::spawn(blockchain::run( tokio::spawn(blockchain::run(
@ -100,6 +109,8 @@ async fn main() {
struct Locals { struct Locals {
tree_debt_g2b: Tree<DebtG2B, ()>, tree_debt_g2b: Tree<DebtG2B, ()>,
bc_send: Sender<DebtG2B>, bc_send: Sender<DebtG2B>,
indexer: String,
pk_str: String,
} }
tokio::task_local! { tokio::task_local! {
@ -110,10 +121,13 @@ async fn main() {
Arc::new(Locals { Arc::new(Locals {
tree_debt_g2b, tree_debt_g2b,
bc_send, bc_send,
indexer: opt.indexer,
pk_str: pk_str,
}), }),
async move { async move {
for request in server.incoming_requests() { for request in server.incoming_requests() {
let locals = LOCALS.get(); let locals = LOCALS.get();
let client = client.clone();
tokio::spawn(async move { tokio::spawn(async move {
let url = request.url(); let url = request.url();
@ -124,7 +138,6 @@ async fn main() {
Some("send") => { Some("send") => {
let address = AccountId32::from_str(url_items.next()?).ok()?; let address = AccountId32::from_str(url_items.next()?).ok()?;
let amount = url_items.next()?.parse::<u64>().ok()?; let amount = url_items.next()?.parse::<u64>().ok()?;
println!("{address} {amount}");
let debt = DebtG2B { address, amount }; let debt = DebtG2B { address, amount };
locals.tree_debt_g2b.insert(&debt, &()).ok()?; locals.tree_debt_g2b.insert(&debt, &()).ok()?;
@ -132,6 +145,52 @@ async fn main() {
Response::empty(200).boxed() Response::empty(200).boxed()
} }
Some("recv") => {
let block_number = url_items.next()?.parse::<u64>().ok()?;
let req = client.post(&locals.indexer).body(dbg!(format!(
r#"[{{"query":"{{transfer(where:{{blockNumber:{{_gt:{}}},to:{{id:{{_eq:\"{}\"}}}}}}){{amount comment{{remark}}}}block(limit:1,orderBy:{{height:DESC}}){{height hash}}}}"}}]"#,
block_number, locals.pk_str))).build().unwrap();
let Ok(res) = client.execute(req).await else {
request.respond(Response::empty(500)).ok();
error!("Error from indexer");
return;
};
let Ok(res) = res.json::<indexer::Response>().await else {
request.respond(Response::empty(500)).ok();
error!("Bad JSON from indexer");
return;
};
let Some(data) = res.first() else {
request.respond(Response::empty(500)).ok();
error!("No data from indexer");
return;
};
let Some(block) = data.data.block.first() else {
request.respond(Response::empty(500)).ok();
error!("No block from indexer");
return;
};
let mut debts = HashMap::<String, u64>::new();
for transfer in data.data.transfer.iter() {
if let Some(comment) = &transfer.comment {
match debts.entry(comment.remark.clone()) {
Entry::Occupied(mut debt) => {
*debt.get_mut() = debt.get().saturating_add(transfer.amount);
}
Entry::Vacant(debt) => {
debt.insert(transfer.amount);
}
}
}
}
Response::from_string(serde_json::to_string(&response::Response {
block: block.height,
debts: debts.into_iter().map(|(name, amount)| response::Debt {
name,
amount,
}).collect(),
}).unwrap()).with_status_code(200).boxed()
}
_ => None?, _ => None?,
} }
}; };

13
g1bridge/src/response.rs Normal file
View file

@ -0,0 +1,13 @@
use serde::Serialize;
#[derive(Serialize)]
pub struct Debt {
pub name: String,
pub amount: u64,
}
#[derive(Serialize)]
pub struct Response {
pub block: u64,
pub debts: Vec<Debt>,
}

View file

@ -1,8 +1,37 @@
local MG_RATE = 10
local http = minetest.request_http_api() local http = minetest.request_http_api()
if http == nil then if http == nil then
error("Please add g1_bridge to secure.http_mods") error("Please add g1_bridge to secure.http_mods")
end end
local mod_storage = core.get_mod_storage()
if mod_storage == nil then
error("Cannot get mod storage")
end
function receive_debts()
local block = mod_storage:get_int("block")
http.fetch({url="http://127.0.0.1:30061/recv/"..block, method="GET"},
function(res)
if res.code == 200 then
local data = minetest.parse_json(res.data)
if data == nil or type(data.block) ~= "number" or data.block <= block or type(data.debts) ~= "table" then
return
end
for i, debt in ipairs(data.debts) do
if type(debt.name) == "string" and type(debt.amount) == "number" and core.player_exists(debt.name) then
local player_debt = mod_storage:get_int("debt_"..debt.name)
player_debt = player_debt + debt.amount
mod_storage:set_int("debt_"..debt.name, player_debt)
end
end
mod_storage:set_int("block", data.block + 1)
end
end
)
end
minetest.register_chatcommand("g1_send", { minetest.register_chatcommand("g1_send", {
params = "<address> <amount>", params = "<address> <amount>",
description = "Send MineGeld from your inventory to a G1 address. Fixed rate: 1G1=10MG.", description = "Send MineGeld from your inventory to a G1 address. Fixed rate: 1G1=10MG.",
@ -14,35 +43,55 @@ minetest.register_chatcommand("g1_send", {
end end
table.insert(args, arg) table.insert(args, arg)
end end
if #args == 3 and string.find(args[1],"^[%da-zA-Z]+$") and string.find(args[2],"^%d+$") then if #args == 2 and string.find(args[1],"^[%da-zA-Z]+$") and string.find(args[2],"^%d+$") then
local address = args[1] local address = args[1]
local amount = tonumber(args[2]) local amount_mg = tonumber(args[2])
-- This version Lua does not have any way to check if a number is represented as an integer or a float, -- This version Lua does not have any way to check if a number is represented as an integer or a float,
-- because it explicitely doesn't care. -- because it explicitely doesn't care.
-- Hence, this program won't care if the player writes an invalid number, -- Hence, this program won't care if the player writes an invalid number,
-- as there is no need to ensure arithmetic rules are respected. (as Lua's manual pretends) -- as there is no need to ensure arithmetic rules are respected. (as Lua's manual pretends)
-- As the pattern excludes "." and nobody should have billions of items, -- As the pattern excludes "." and nobody should have billions of items,
-- it should still be safe. I hope. -- it should still be safe. I hope.
if type(amount) ~= "number" then if type(amount_mg) ~= "number" then
return return
end end
local inv = core.get_inventory({type="player", name=name}) local inv = core.get_inventory({type="player", name=name})
local wanted_stack = "currency:minegeld "..amount local wanted_stack = "currency:minegeld "..amount_mg
local balanced = inv:contains_item("main", ItemStack(wanted_stack)) local balanced = inv:contains_item("main", ItemStack(wanted_stack))
if balanced then if balanced then
inv:remove_item("main", wanted_stack) local amount = amount_mg * MG_RATE
inv:remove_item("main", ItemStack(wanted_stack))
http.fetch({url="http://127.0.0.1:30061/send/"..address.."/"..amount, method="GET"}, http.fetch({url="http://127.0.0.1:30061/send/"..address.."/"..amount, method="GET"},
function(res) function(res)
if res.code == 200 then if res ~= nil and res.code == 200 then
core.chat_send_player(name, "Transfer sent") core.chat_send_player(name, "Transfer sent")
else else
core.chat_send_player(name, "Error during transfer") core.chat_send_player(name, "Error during transfer, giving your money back")
inv:add_item("main", ItemStack(wanted_stack))
end end
end end
) )
else else
core.chat_send_player(name, "Not enough currency") core.chat_send_player(name, "Not enough money (please ensure it is available in 1MG banknotes)")
end end
end end
end end
}) })
minetest.register_chatcommand("g1_claim", {
description = "Claim MineGeld from G1 sent to the server",
func = function(name, param)
receive_debts()
local amount = mod_storage:get_int("debt_"..name)
local amount_mg = amount / MG_RATE
local inv = core.get_inventory({type="player", name=name})
local stack = "currency:minegeld "..amount_mg
if inv:room_for_item("main", ItemStack(stack)) then
inv:add_item("main", ItemStack(stack))
core.chat_send_player(name, "Claimed "..amount_mg.."MG")
mod_storage:set_int("debt_"..name, 0)
else
core.chat_send_player(name, "No room in your inventory! Available: "..amount_mg.."MG")
end
end
})