Skip to content

Commit 34d8413

Browse files
committed
0.1.0
1 parent aa20a98 commit 34d8413

13 files changed

+1069
-0
lines changed

.python-version

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
3.11

README.md

+33
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
# Maat
2+
A Program to profitably rebalance prices between Penumbra and Osmosis.
3+
(aka an arbitrage bot)
4+
## Setup
5+
Program expects chain binaries to be available in path, with wallets initialized and appropriate balances available. `pcli` should be initialized with `soft-kms` and GRPC of choice. Cosmos-SDK binaries should have keys loaded into `keyring-backend=test`.
6+
### Example
7+
`pcli init --grpc-url https://void.s9.gay soft-kms generate`
8+
9+
`osmosisd keys add Maat --keyring-backend=test`
10+
11+
## Usage
12+
Project is managed with the python package/project manager [uv](https://github.com/astral-sh/uv).
13+
14+
Runtime configuration can be provided with `config.toml`, or with command line arguments which override config file values.
15+
### Example `config.toml`
16+
```
17+
TokenA = "UM"
18+
TokenB = "OSMO"
19+
20+
[Osmosis]
21+
RPC = "https://rpc.osmosis.zone"
22+
Wallet = "Maat"
23+
24+
[Penumbra]
25+
RPC = "https://void.s9.gay"
26+
Account = "0"
27+
```
28+
### Default Usage
29+
`uv run maat.py`
30+
### Equivalent Args
31+
`uv run maat.py --TokenA UM --TokenB OSMO --Osmosis_Wallet Maat --Penumbra_Account 0`
32+
### UM/USDC Example
33+
`uv run maat.py --TokenA UM --TokenB USDC --Osmosis_Wallet Maat --Noble_Wallet Maat --Penumbra_Account 1`

config.toml

+23
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
TokenA = "UM"
2+
TokenB = "OSMO"
3+
4+
[Celestia]
5+
RPC = "https://celestia-rpc.polkachu.com"
6+
Wallet = "Maat"
7+
8+
[Cosmos]
9+
RPC = "https://cosmos-rpc.polkachu.com"
10+
Wallet = "Maat"
11+
12+
[Noble]
13+
RPC = "https://noble-rpc.polkachu.com:443"
14+
Wallet = "Maat"
15+
16+
[Osmosis]
17+
RPC = "https://rpc.osmosis.zone"
18+
Wallet = "Maat"
19+
20+
[Penumbra]
21+
RPC = "https://void.s9.gay"
22+
Account = "0"
23+

maat.py

+14
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
from src.Arbitrage import arbitrage
2+
from src.Rebalance import rebalance
3+
from src.Args import Args
4+
5+
6+
def main():
7+
print("Hello from Maat!")
8+
while True:
9+
rebalance(Args.TokenA, Args.TokenB)
10+
arbitrage(Args.TokenA, Args.TokenB)
11+
12+
13+
if __name__ == "__main__":
14+
main()

pyproject.toml

+9
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
[project]
2+
name = "maat"
3+
version = "0.1.0"
4+
description = "Penumbra <=> Osmosis rebalancing arbitrage bot"
5+
readme = "README.md"
6+
requires-python = ">=3.11"
7+
dependencies = [
8+
"requests>=2.32.3",
9+
]

src/Arbitrage.py

+177
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
from src.Query import (
2+
simulate_osmosis_swap,
3+
simulate_penumbra_swap,
4+
get_osmosis_balance,
5+
get_penumbra_balance,
6+
)
7+
from src.Consts import get_decimals, get_min
8+
from src.Swap import osmosis_swap, penumbra_swap
9+
import time
10+
11+
min_diff = 0.01
12+
# only search when at least 1% price difference at minimum size
13+
14+
15+
def arbitrage(A, B):
16+
minA = get_min(A)
17+
minB = get_min(B)
18+
maxOA = get_osmosis_balance(A) - minA
19+
maxOB = get_osmosis_balance(B) - minB
20+
maxPA = get_penumbra_balance(A) - minA
21+
maxPB = get_penumbra_balance(B) - minB
22+
OAB_cache = {}
23+
OBA_cache = {}
24+
PAB_cache = {}
25+
PBA_cache = {}
26+
27+
def oab_swap(a):
28+
if a in OAB_cache:
29+
return OAB_cache[a]
30+
(o, path) = simulate_osmosis_swap(A, B, a)
31+
OAB_cache[a] = o
32+
return o
33+
34+
def oba_swap(b):
35+
if B in OBA_cache:
36+
return OBA_cache[b]
37+
(o, path) = simulate_osmosis_swap(B, A, b)
38+
OBA_cache[b] = o
39+
return o
40+
41+
def pab_swap(a):
42+
if a in PAB_cache:
43+
return PAB_cache[a]
44+
o = simulate_penumbra_swap(A, B, a)
45+
PAB_cache[a] = o
46+
return o
47+
48+
def pba_swap(b):
49+
if b in PBA_cache:
50+
return PBA_cache[b]
51+
o = simulate_penumbra_swap(B, A, b)
52+
PBA_cache[b] = o
53+
return o
54+
55+
def solve_direction():
56+
oab_pba = pba_swap(oab_swap(minA)) / minA
57+
print(f"swap osmosis({A}=>{B}), penumbra({B} => {A})", oab_pba)
58+
oba_pab = pab_swap(oba_swap(minB)) / minB
59+
print(f"swap osmosis({B}=>{A}), penumbra({A} => {B})", oba_pab)
60+
61+
if oab_pba > (1 + min_diff):
62+
print(f"potential arb: osmosis({A}=>{B}), penumbra({B} => {A})", oab_pba)
63+
return True
64+
elif oba_pab > (1 + min_diff):
65+
print(f"potential arb: osmosis({B}=>{A}), penumbra({A} => {B})", oba_pab)
66+
return False
67+
return None
68+
69+
# successive approximation with 5 sample points spaces across range
70+
# recursively search subrange bounded by neighbors of best point
71+
def solve_func(f, low, high, tol):
72+
if (high - low) < tol:
73+
return (low + high) // 2
74+
75+
def i_to_x(i):
76+
return low + (i * (high - low)) // 4
77+
78+
points = [(f(i_to_x(i)), i) for i in range(5)]
79+
(besty, besti) = max(points)
80+
81+
return solve_func(
82+
f, max(low, i_to_x(besti - 1)), min(high, i_to_x(besti + 1)), tol
83+
)
84+
85+
# solve A->B->A for max profit in A token
86+
def solve_A(dir):
87+
if dir:
88+
return solve_func(
89+
lambda x: pba_swap(min(maxPB, oab_swap(x))) - x, minA, maxOA, minA
90+
)
91+
else:
92+
return solve_func(
93+
lambda x: oba_swap(min(maxOB, pab_swap(x))) - x, minA, maxPA, minA
94+
)
95+
96+
# solve B->A (assuming AIn -> EBout) for max balanced profit in A and B token
97+
def solve_B(dir, AIn, EBOut):
98+
if dir:
99+
return solve_func(
100+
lambda y: (pba_swap(y) - AIn) * (EBOut - y) * (EBOut / AIn),
101+
minB,
102+
min(EBOut, maxPB),
103+
minB,
104+
)
105+
else:
106+
return solve_func(
107+
lambda y: (pba_swap(y) - AIn) * (EBOut - y) * (EBOut / AIn),
108+
minB,
109+
min(EBOut, maxOB),
110+
minB,
111+
)
112+
113+
dir = solve_direction()
114+
if dir is None:
115+
print("no arb found")
116+
print("sleeping 300 seconds")
117+
time.sleep(300)
118+
return
119+
120+
if dir:
121+
AIn = solve_A(dir)
122+
print("A", AIn / get_decimals(A))
123+
EBOut = oab_swap(AIn)
124+
BIn = solve_B(dir, AIn, EBOut)
125+
print("B", BIn / get_decimals(B))
126+
EAOut = pba_swap(BIn)
127+
print(
128+
f"best solve: osmosis({AIn/get_decimals(A)} {A} => {EBOut/get_decimals(B)} {B}), penumbra({BIn/get_decimals(B)} {B} => {EAOut/get_decimals(A)} {A})"
129+
)
130+
print(
131+
"Expected Net Effect: ",
132+
(EAOut - AIn) / get_decimals(A),
133+
A,
134+
(EBOut - BIn) / get_decimals(B),
135+
B,
136+
)
137+
if (EAOut - AIn) > 0 and (EBOut - BIn) > 0:
138+
BOut = osmosis_swap(A, B, AIn)
139+
AOut = penumbra_swap(B, A, BIn)
140+
print(
141+
"Arb Completed, Net Effect: ",
142+
(AOut - AIn) / get_decimals(A),
143+
A,
144+
(BOut - BIn) / get_decimals(B),
145+
B,
146+
)
147+
else:
148+
print("Expected Net Effect contains a negative profit, skipping")
149+
else:
150+
AIn = solve_A(dir)
151+
print("A", AIn / get_decimals(A))
152+
EBOut = pab_swap(AIn)
153+
BIn = solve_B(dir, AIn, EBOut)
154+
print("B", BIn / get_decimals(B))
155+
EAOut = oba_swap(BIn)
156+
print(
157+
f"best solve: osmosis({BIn/get_decimals(B)} {B} => {EAOut/get_decimals(A)} {A}), penumbra({AIn/get_decimals(A)} {A} => {EBOut/get_decimals(B)} {B})",
158+
)
159+
print(
160+
"Expected Net Effect: ",
161+
(EAOut - AIn) / get_decimals(A),
162+
A,
163+
(EBOut - BIn) / get_decimals(B),
164+
B,
165+
)
166+
if (EAOut - AIn) > 0 and (EBOut - BIn) > 0:
167+
AOut = osmosis_swap(B, A, BIn)
168+
BOut = penumbra_swap(A, B, AIn)
169+
print(
170+
"Arb Completed, Net Effect: ",
171+
(AOut - AIn) / get_decimals(A),
172+
A,
173+
(BOut - BIn) / get_decimals(B),
174+
B,
175+
)
176+
else:
177+
print("Expected Net Effect contains a negative profit, skipping")

src/Args.py

+63
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import argparse
2+
import tomllib
3+
4+
5+
with open("config.toml", "rb") as f:
6+
config = tomllib.load(f)
7+
8+
print(config)
9+
10+
parser = argparse.ArgumentParser(
11+
prog="Maat", description="Penumbra <=> Osmosis rebalancing arbitrage bot"
12+
)
13+
14+
parser.add_argument("--TokenA", default=config.get("TokenA", "UM"))
15+
parser.add_argument("--TokenB", default=config.get("TokenB", "OSMO"))
16+
17+
parser.add_argument(
18+
"--Celestia_RPC",
19+
default=config.get("Celestia", {}).get("RPC", "https://celestia-rpc.polkachu.com"),
20+
)
21+
parser.add_argument(
22+
"--Cosmos_RPC",
23+
default=config.get("Cosmos", {}).get("RPC", "https://cosmos-rpc.polkachu.com"),
24+
)
25+
parser.add_argument(
26+
"--Noble_RPC",
27+
default=config.get("Noble", {}).get("RPC", "https://noble-rpc.polkachu.com:443"),
28+
)
29+
parser.add_argument(
30+
"--Osmosis_RPC",
31+
default=config.get("Osmosis", {}).get("RPC", "https://rpc.osmosis.zone"),
32+
)
33+
parser.add_argument(
34+
"--Penumbra_RPC",
35+
default=config.get("Penumbra", {}).get("RPC", "https://void.s9.gay"),
36+
)
37+
38+
39+
parser.add_argument(
40+
"--Celestia_Wallet", default=config.get("Celestia", {}).get("Wallet", "Maat")
41+
)
42+
parser.add_argument(
43+
"--Cosmos_Wallet", default=config.get("Cosmos", {}).get("Wallet", "Maat")
44+
)
45+
parser.add_argument(
46+
"--Noble_Wallet", default=config.get("Noble", {}).get("Wallet", "Maat")
47+
)
48+
parser.add_argument(
49+
"--Osmosis_Wallet", default=config.get("Osmosis", {}).get("Wallet", "Maat")
50+
)
51+
parser.add_argument(
52+
"--Penumbra_Account", default=config.get("Penumbra", {}).get("Account", "0")
53+
)
54+
55+
Args = parser.parse_args()
56+
57+
58+
def get_rpc(chain):
59+
return vars(Args)[f"{chain}_RPC"]
60+
61+
62+
def get_wallet(chain):
63+
return vars(Args)[f"{chain}_Wallet"]

0 commit comments

Comments
 (0)