Compare commits

...

2 commits

Author SHA1 Message Date
2bab960f23 Receive 2025-06-22 22:10:41 +02:00
a54ac76523 send command: simplify, description 2025-06-13 22:04:48 +02:00
10 changed files with 62527 additions and 936 deletions

View file

@ -3,7 +3,7 @@
Luanti mod to exchange in-game [currency](https://github.com/mt-mods/currency) with [libre currency](https://duniter.org) (Ğ1 blockchain). Luanti mod to exchange in-game [currency](https://github.com/mt-mods/currency) with [libre currency](https://duniter.org) (Ğ1 blockchain).
Luanti server has a dedicated account. Luanti server has a dedicated account.
Player transfer money on it and the server gives them a corresponding amount of in-game banknotes. Players transfer money on it and the server gives them a corresponding amount of in-game banknotes.
They can then do whatever they want with the banknotes (trade, decorate walls, burn...). They can then do whatever they want with the banknotes (trade, decorate walls, burn...).
Players can ask the server to transfer their banknotes to an account of their choice using a chat command. Players can ask the server to transfer their banknotes to an account of their choice using a chat command.
@ -13,6 +13,8 @@ It works with Ğ1v2 which is still in development/testing phase.
Put this repo in the `mods` directory. Put this repo in the `mods` directory.
Add `g1_bridge` to `secure.http_mods`.
Have a local Duniter node. Have a local Duniter node.
```bash ```bash
@ -31,6 +33,7 @@ Have a local Duniter node.
* Ensure all transfers are failproof (any communication, storage medium, server or whatever should be able to fail or rollback to a previous state, without causing double spend or money loss) * Ensure all transfers are failproof (any communication, storage medium, server or whatever should be able to fail or rollback to a previous state, without causing double spend or money loss)
* Force reception account to exist * Force reception account to exist
* Nice ATM-like interface * Nice ATM-like interface
* Accept mixed values (other banknotes values than 1)
## Workflow ## Workflow

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>,
}

112
init.lua
View file

@ -1,49 +1,97 @@
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
minetest.register_chatcommand("g1", { 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", {
params = "<address> <amount>",
description = "Send MineGeld from your inventory to a G1 address. Fixed rate: 1G1=10MG.",
func = function(name, param) func = function(name, param)
local args = {} local args = {}
for arg in string.gmatch(param,"%S+") do for arg in string.gmatch(param,"%S+") do
if #args >= 3 then if #args >= 2 then
break break
end end
table.insert(args, arg) table.insert(args, arg)
end end
if #args >= 1 then if #args == 2 and string.find(args[1],"^[%da-zA-Z]+$") and string.find(args[2],"^%d+$") then
core.chat_send_player(name, "args >= 1") local address = args[1]
if args[1] == "send" and #args == 3 and string.find(args[2],"^[%da-zA-Z]+$") and string.find(args[3],"^%d+$") then local amount_mg = tonumber(args[2])
local address = args[2] -- This version Lua does not have any way to check if a number is represented as an integer or a float,
local amount = tonumber(args[3]) -- because it explicitely doesn't care.
-- This version Lua does not have any way to check if a number is represented as an integer or a float, -- Hence, this program won't care if the player writes an invalid number,
-- because it explicitely doesn't care. -- as there is no need to ensure arithmetic rules are respected. (as Lua's manual pretends)
-- Hence, this program won't care if the player writes an invalid number, -- As the pattern excludes "." and nobody should have billions of items,
-- as there is no need to ensure arithmetic rules are respected. (as Lua's manual pretends) -- it should still be safe. I hope.
-- As the pattern excludes "." and nobody should have billions of items, if type(amount_mg) ~= "number" then
-- it should still be safe. I hope. return
if type(amount) ~= "number" then end
return local inv = core.get_inventory({type="player", name=name})
end local wanted_stack = "currency:minegeld "..amount_mg
local inv = core.get_inventory({type="player", name=name}) local balanced = inv:contains_item("main", ItemStack(wanted_stack))
local wanted_stack = "currency:minegeld "..amount if balanced then
local balanced = inv:contains_item("main", ItemStack(wanted_stack)) local amount = amount_mg * MG_RATE
if balanced then inv:remove_item("main", ItemStack(wanted_stack))
inv:remove_item("main", 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 ~= nil and res.code == 200 then
if 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, giving your money back")
core.chat_send_player(name, "Error during transfer") inv:add_item("main", ItemStack(wanted_stack))
end
end end
) end
else )
core.chat_send_player(name, "Not enough currency") else
end 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
})