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 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...).
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.
Add `g1_bridge` to `secure.http_mods`.
Have a local Duniter node.
```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)
* Force reception account to exist
* Nice ATM-like interface
* Accept mixed values (other banknotes values than 1)
## 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]
argp = "0.4.0"
codec = { package = "parity-scale-codec", version = "3.6.12" }
hex = "0.4.3"
log = "0.4.27"
reqwest = { version = "0.11.27", features = ["json"] }
scrypt = { version = "0.11.0", default-features = false }
serde = { version = "1.0.219", features = ["derive"] }
serde_json = "1.0.140"
simplelog = "0.12.2"
sp-core = { 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.11.0" }
subxt = { git = 'https://github.com/duniter/subxt', branch = 'subxt-v0.35.3-duniter-substrate-v1.11.0', default-features = false, features = [
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.14.0" }
subxt = { git = 'https://github.com/duniter/subxt', branch = 'subxt-v0.37.0-duniter-substrate-v1.14.0', default-features = false, features = [
"substrate-compat",
"native",
"jsonrpsee",
@ -21,5 +24,10 @@ tiny_http = "0.12.0"
tokio = { version = "1.38.0", features = ["macros", "rt-multi-thread"] }
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]
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)]
mod blockchain;
mod indexer;
mod response;
use argp::FromArgs;
use serde::{Deserialize, Serialize, de};
use serde::{Deserialize, Serialize};
use sp_core::Pair as _;
use std::{
io::{BufRead, Read},
str::FromStr,
sync::Arc,
collections::{hash_map::Entry, HashMap}, io::{BufRead, Read}, str::FromStr, sync::Arc
};
use subxt::{
ext::sp_core::ed25519::{Pair, Public},
utils::{AccountId32, MultiAddress},
ext::sp_core::ed25519::Pair,
utils::AccountId32,
};
use tiny_http::{Response, ResponseBox, Server, ServerConfig};
use tiny_http::{Response, ResponseBox, Server};
use tokio::sync::mpsc::{Sender, channel};
use typed_sled::Tree;
use log::error;
/// Game to Blockchain
const TREE_DEBT_G2B: &'static str = "debt_g2b";
@ -28,6 +29,10 @@ struct Opt {
#[argp(option, short = 'd')]
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)
#[argp(switch, short = 'l')]
legacy: bool,
@ -80,12 +85,16 @@ async fn main() {
.expect("Cannot read secret key file");
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();
let db = typed_sled::open(opt.db).expect("Cannot open database");
let tree_debt_g2b = Tree::<DebtG2B, ()>::open(&db, TREE_DEBT_G2B);
let client = reqwest::Client::new();
let (bc_send, bc_recv) = channel(128);
tokio::spawn(blockchain::run(
@ -100,6 +109,8 @@ async fn main() {
struct Locals {
tree_debt_g2b: Tree<DebtG2B, ()>,
bc_send: Sender<DebtG2B>,
indexer: String,
pk_str: String,
}
tokio::task_local! {
@ -110,10 +121,13 @@ async fn main() {
Arc::new(Locals {
tree_debt_g2b,
bc_send,
indexer: opt.indexer,
pk_str: pk_str,
}),
async move {
for request in server.incoming_requests() {
let locals = LOCALS.get();
let client = client.clone();
tokio::spawn(async move {
let url = request.url();
@ -124,7 +138,6 @@ async fn main() {
Some("send") => {
let address = AccountId32::from_str(url_items.next()?).ok()?;
let amount = url_items.next()?.parse::<u64>().ok()?;
println!("{address} {amount}");
let debt = DebtG2B { address, amount };
locals.tree_debt_g2b.insert(&debt, &()).ok()?;
@ -132,6 +145,52 @@ async fn main() {
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?,
}
};

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()
if http == nil then
error("Please add g1_bridge to secure.http_mods")
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)
local args = {}
for arg in string.gmatch(param,"%S+") do
if #args >= 3 then
if #args >= 2 then
break
end
table.insert(args, arg)
end
if #args >= 1 then
core.chat_send_player(name, "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 address = args[2]
local amount = tonumber(args[3])
-- 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.
-- 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 the pattern excludes "." and nobody should have billions of items,
-- it should still be safe. I hope.
if type(amount) ~= "number" then
return
end
local inv = core.get_inventory({type="player", name=name})
local wanted_stack = "currency:minegeld "..amount
local balanced = inv:contains_item("main", ItemStack(wanted_stack))
if balanced then
inv:remove_item("main", wanted_stack)
http.fetch({url="http://127.0.0.1:30061/send/"..address.."/"..amount, method="GET"},
function(res)
if res.code == 200 then
core.chat_send_player(name, "Transfer sent")
else
core.chat_send_player(name, "Error during transfer")
end
if #args == 2 and string.find(args[1],"^[%da-zA-Z]+$") and string.find(args[2],"^%d+$") then
local address = args[1]
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,
-- because it explicitely doesn't care.
-- 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 the pattern excludes "." and nobody should have billions of items,
-- it should still be safe. I hope.
if type(amount_mg) ~= "number" then
return
end
local inv = core.get_inventory({type="player", name=name})
local wanted_stack = "currency:minegeld "..amount_mg
local balanced = inv:contains_item("main", ItemStack(wanted_stack))
if balanced then
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"},
function(res)
if res ~= nil and res.code == 200 then
core.chat_send_player(name, "Transfer sent")
else
core.chat_send_player(name, "Error during transfer, giving your money back")
inv:add_item("main", ItemStack(wanted_stack))
end
)
else
core.chat_send_player(name, "Not enough currency")
end
end
)
else
core.chat_send_player(name, "Not enough money (please ensure it is available in 1MG banknotes)")
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
})