- Institutional / JVMTM / reserve-provenance / GRU transport + standards JSON - Validation and verify scripts (Blockscout labels, x402, GRU preflight, P1 local path) - Wormhole wiring in AGENTS, MCP_SETUP, MASTER_INDEX, 04-configuration README - Meta docs, integration gaps, live verification log, architecture updates - CI validate-config workflow updates Operator/LAN items, submodule working trees, and public token-aggregation edge routes remain follow-up (see TODOS_CONSOLIDATED P1). Made-with: Cursor
270 lines
9.7 KiB
Bash
Executable File
270 lines
9.7 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# Generate three-way reconciliation JSON from Fineract (ledger) + optional bank file/env + Chain 138 ERC20 balance.
|
|
# Operational evidence: bank leg requires operator-supplied statement/API (file or env). See
|
|
# config/jvmtm-regulatory-closure/OPERATIONAL_EVIDENCE_VS_TEMPLATES.md
|
|
#
|
|
# Env (after sourcing load-project-env):
|
|
# OMNL_FINERACT_BASE_URL, OMNL_FINERACT_USER, OMNL_FINERACT_PASSWORD, OMNL_FINERACT_TENANT (default omnl)
|
|
# RECON_OFFICE_ID (default 21), RECON_GL_CODE (default 2100)
|
|
# RECON_TOKEN_ADDRESS (default canonical cUSDT on 138), RECON_CHAIN_HOLDER (default deployer), RECON_TOKEN_DECIMALS (default 6)
|
|
# JVMTM_CORRELATION_ID — use real UUID for examination (not literal PLACEHOLDER)
|
|
# JVMTM_BANK_BALANCE_JSON — path: {"value_major","statement_ref","fetched_at"?}
|
|
# JVMTM_BANK_BALANCE_MAJOR + JVMTM_BANK_STATEMENT_REF — alternative
|
|
# JVMTM_EVIDENCE_DIR — default REPO/output/jvmtm-evidence
|
|
# AS_OF — YYYY-MM-DD (default UTC today)
|
|
#
|
|
# Output: 3way-<AS_OF>.json + latest-3way-result.json
|
|
#
|
|
set -eo pipefail
|
|
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
|
# shellcheck source=scripts/lib/load-project-env.sh
|
|
set +u
|
|
source "${REPO_ROOT}/scripts/lib/load-project-env.sh"
|
|
if [[ -f "${REPO_ROOT}/omnl-fineract/.env" ]]; then
|
|
set -a
|
|
# shellcheck disable=SC1090
|
|
source "${REPO_ROOT}/omnl-fineract/.env" 2>/dev/null || true
|
|
set +a
|
|
fi
|
|
set -euo pipefail
|
|
|
|
AS_OF="${AS_OF:-$(date -u +%Y-%m-%d)}"
|
|
OUT_DIR="${JVMTM_EVIDENCE_DIR:-${REPO_ROOT}/output/jvmtm-evidence}"
|
|
mkdir -p "$OUT_DIR"
|
|
|
|
OFFICE_ID="${RECON_OFFICE_ID:-21}"
|
|
GL_CODE="${RECON_GL_CODE:-2100}"
|
|
CORR="${JVMTM_CORRELATION_ID:-PLACEHOLDER}"
|
|
TOKEN_ADDR="${RECON_TOKEN_ADDRESS:-0x93E66202A11B1772E55407B32B44e5Cd8eda7f22}"
|
|
HOLDER="${RECON_CHAIN_HOLDER:-0x4A666F96fC8764181194447A7dFdb7d471b301C8}"
|
|
DECIMALS="${RECON_TOKEN_DECIMALS:-6}"
|
|
RPC="${RPC_URL_138:-http://192.168.11.211:8545}"
|
|
|
|
REPORT_ID="3WAY-GEN-${AS_OF}-$(date -u +%H%M%S)"
|
|
GAPS=()
|
|
LEDGER_SOURCE="fineract:/glaccounts"
|
|
CHAIN_SOURCE="cast:erc20_balanceOf"
|
|
|
|
RPC_HOST="$(RPC_URL="$RPC" python3 -c "from urllib.parse import urlparse; import os; print(urlparse(os.environ['RPC_URL']).hostname or os.environ['RPC_URL'])")"
|
|
|
|
BASE_URL="${OMNL_FINERACT_BASE_URL:-}"
|
|
PASS="${OMNL_FINERACT_PASSWORD:-}"
|
|
USER="${OMNL_FINERACT_USER:-app.omnl}"
|
|
TENANT="${OMNL_FINERACT_TENANT:-omnl}"
|
|
|
|
if [[ -n "$BASE_URL" && -n "$PASS" ]]; then
|
|
GL_RAW="$(curl -sS -H "Fineract-Platform-TenantId: ${TENANT}" -u "${USER}:${PASS}" "${BASE_URL}/glaccounts" || true)"
|
|
LEDGER_BLOCK="$(GL_RAW="$GL_RAW" OFFICE_ID="$OFFICE_ID" GL_CODE="$GL_CODE" python3 <<'PY'
|
|
import json, os
|
|
from datetime import datetime, timezone
|
|
office = int(os.environ["OFFICE_ID"])
|
|
code = os.environ["GL_CODE"]
|
|
raw = os.environ.get("GL_RAW", "[]")
|
|
try:
|
|
data = json.loads(raw)
|
|
except json.JSONDecodeError:
|
|
data = []
|
|
if isinstance(data, dict) and "pageItems" in data:
|
|
data = data["pageItems"]
|
|
rows = [
|
|
x for x in data
|
|
if isinstance(x, dict) and str(x.get("glCode")) == code
|
|
and (x.get("officeId") == office or x.get("officeId") is None)
|
|
]
|
|
acc = rows[0] if rows else {}
|
|
bal = acc.get("organizationRunningBalance")
|
|
if bal is None:
|
|
bal = acc.get("runningBalance")
|
|
if bal is None:
|
|
s = acc.get("summary")
|
|
if isinstance(s, dict):
|
|
bal = s.get("runningBalance")
|
|
out = {
|
|
"value_major": None,
|
|
"source": "fineract:/glaccounts",
|
|
"fetched_at": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
|
"gl_code": code,
|
|
"office_id": office,
|
|
"gl_account_id": acc.get("id"),
|
|
"raw_field": "organizationRunningBalance|runningBalance",
|
|
}
|
|
if bal is not None:
|
|
out["value_major"] = str(bal)
|
|
print(json.dumps({"ledger_line": out, "found": bool(acc)}))
|
|
PY
|
|
)"
|
|
LEDGER_VAL="$(echo "$LEDGER_BLOCK" | jq -r '.ledger_line.value_major // empty')"
|
|
if [[ "$(echo "$LEDGER_BLOCK" | jq -r '.found')" != "true" ]] || [[ -z "$LEDGER_VAL" ]]; then
|
|
GAPS+=("fineract_gl_balance_missing")
|
|
fi
|
|
LEDGER_JSON="$(echo "$LEDGER_BLOCK" | jq -c '.ledger_line')"
|
|
else
|
|
GAPS+=("fineract_unreachable_or_unconfigured")
|
|
LEDGER_JSON="$(jq -nc \
|
|
--arg s "$LEDGER_SOURCE" \
|
|
--arg ft "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
|
|
--argjson oid "$OFFICE_ID" \
|
|
--arg gc "$GL_CODE" \
|
|
'{value_major: null, source: $s, fetched_at: $ft, gl_code: $gc, office_id: $oid, raw_field: "n/a"}')"
|
|
fi
|
|
|
|
if command -v cast &>/dev/null; then
|
|
RAW_BAL="$(cast call "$TOKEN_ADDR" "balanceOf(address)(uint256)" "$HOLDER" --rpc-url "$RPC" 2>/dev/null || echo "")"
|
|
if [[ -n "$RAW_BAL" ]]; then
|
|
RAW_ONE="$(echo "$RAW_BAL" | awk '{print $1}')"
|
|
CHAIN_JSON="$(RAW_BAL="$RAW_ONE" DECIMALS="$DECIMALS" TOKEN_ADDR="$TOKEN_ADDR" HOLDER="$HOLDER" RPC_HOST="$RPC_HOST" python3 <<'PY'
|
|
import os, json
|
|
from decimal import Decimal
|
|
from datetime import datetime, timezone
|
|
raw = int(os.environ["RAW_BAL"].strip(), 0)
|
|
dec = int(os.environ["DECIMALS"])
|
|
major = str(Decimal(raw) / (Decimal(10) ** dec))
|
|
out = {
|
|
"value_major": major,
|
|
"source": "cast:erc20_balanceOf",
|
|
"fetched_at": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
|
"rpc_url_host": os.environ.get("RPC_HOST", ""),
|
|
"chain_id": 138,
|
|
"token_address": os.environ["TOKEN_ADDR"],
|
|
"holder_address": os.environ["HOLDER"],
|
|
"decimals": dec,
|
|
}
|
|
print(json.dumps(out))
|
|
PY
|
|
)"
|
|
else
|
|
GAPS+=("chain_balance_query_failed")
|
|
CHAIN_JSON="$(jq -nc \
|
|
--arg s "$CHAIN_SOURCE" \
|
|
--arg ft "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
|
|
--arg th "$RPC_HOST" \
|
|
--arg ta "$TOKEN_ADDR" \
|
|
--arg hd "$HOLDER" \
|
|
--argjson dec "$DECIMALS" \
|
|
'{value_major: null, source: $s, fetched_at: $ft, rpc_url_host: $th, chain_id: 138, token_address: $ta, holder_address: $hd, decimals: $dec}')"
|
|
fi
|
|
else
|
|
GAPS+=("cast_not_installed")
|
|
CHAIN_JSON="$(jq -nc \
|
|
--arg s "$CHAIN_SOURCE" \
|
|
--arg ft "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
|
|
--arg th "$RPC_HOST" \
|
|
--arg ta "$TOKEN_ADDR" \
|
|
--arg hd "$HOLDER" \
|
|
--argjson dec "$DECIMALS" \
|
|
'{value_major: null, source: $s, fetched_at: $ft, rpc_url_host: $th, chain_id: 138, token_address: $ta, holder_address: $hd, decimals: $dec}')"
|
|
fi
|
|
|
|
if [[ -n "${JVMTM_BANK_BALANCE_JSON:-}" && -f "${JVMTM_BANK_BALANCE_JSON}" ]]; then
|
|
BANK_JSON="$(jq -c \
|
|
--arg now "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
|
|
'{value_major: .value_major, source: (.source // "operator:jvmtm_bank_json_file"), fetched_at: (.fetched_at // $now), statement_ref: .statement_ref}' \
|
|
"${JVMTM_BANK_BALANCE_JSON}")"
|
|
BANK_VAL="$(echo "$BANK_JSON" | jq -r '.value_major // empty')"
|
|
if [[ -z "$BANK_VAL" ]]; then
|
|
GAPS+=("bank_file_missing_value_major")
|
|
BANK_JSON="null"
|
|
fi
|
|
elif [[ -n "${JVMTM_BANK_BALANCE_MAJOR:-}" ]]; then
|
|
BANK_JSON="$(jq -nc \
|
|
--arg v "${JVMTM_BANK_BALANCE_MAJOR}" \
|
|
--arg r "${JVMTM_BANK_STATEMENT_REF:-nostro-export}" \
|
|
--arg ft "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
|
|
'{value_major: $v, source: "operator:env_JVMTM_BANK_BALANCE_MAJOR", fetched_at: $ft, statement_ref: $r}')"
|
|
else
|
|
GAPS+=("bank_statement_not_supplied")
|
|
BANK_JSON="null"
|
|
fi
|
|
|
|
[[ ${#CORR} -lt 8 ]] && GAPS+=("correlation_id_too_short_use_JVMTM_CORRELATION_ID")
|
|
[[ "$CORR" == "PLACEHOLDER" ]] && GAPS+=("correlation_id_placeholder_not_examination_grade")
|
|
|
|
GAPS_JSON="$(printf '%s\n' "${GAPS[@]}" | jq -R -s -c 'split("\n") | map(select(length>0))')"
|
|
ARGV_JSON="$(python3 -c 'import json,sys; print(json.dumps(sys.argv[1:]))' -- "$@")"
|
|
|
|
export LEDGER_JSON CHAIN_JSON BANK_JSON GAPS_JSON CORR REPORT_ID AS_OF ARGV_JSON
|
|
|
|
FINAL_JSON="$(python3 <<'PY'
|
|
import json, os
|
|
from decimal import Decimal, InvalidOperation
|
|
|
|
def D(x):
|
|
if x is None or x == "":
|
|
return None
|
|
try:
|
|
return Decimal(str(x))
|
|
except InvalidOperation:
|
|
return None
|
|
|
|
ledger = json.loads(os.environ["LEDGER_JSON"])
|
|
chain = json.loads(os.environ["CHAIN_JSON"])
|
|
bank_s = os.environ["BANK_JSON"]
|
|
bank = json.loads(bank_s) if bank_s != "null" else None
|
|
gaps = json.loads(os.environ["GAPS_JSON"])
|
|
corr = os.environ["CORR"]
|
|
report_id = os.environ["REPORT_ID"]
|
|
as_of = os.environ["AS_OF"]
|
|
argv = json.loads(os.environ["ARGV_JSON"])
|
|
|
|
lv = D(ledger.get("value_major"))
|
|
cv = D(chain.get("value_major") if chain else None)
|
|
bv = D(bank.get("value_major") if bank else None)
|
|
|
|
eps = Decimal("0.01")
|
|
|
|
def sub(a, b):
|
|
if a is None or b is None:
|
|
return None
|
|
return str(a - b)
|
|
|
|
var = {
|
|
"ledger_vs_bank_major": sub(lv, bv) if bv is not None else "n/a",
|
|
"ledger_vs_chain_major": sub(lv, cv) if cv is not None else "n/a",
|
|
"bank_vs_chain_major": sub(bv, cv) if bv is not None and cv is not None else "n/a",
|
|
}
|
|
|
|
matched = False
|
|
if lv is not None and cv is not None and bv is not None:
|
|
matched = abs(lv - cv) <= eps and abs(lv - bv) <= eps and abs(bv - cv) <= eps
|
|
elif lv is not None and cv is not None and bv is None:
|
|
matched = abs(lv - cv) <= eps
|
|
|
|
if any(g in gaps for g in ("bank_statement_not_supplied", "bank_file_missing_value_major")):
|
|
tier = "GENERATED_PARTIAL"
|
|
elif not gaps:
|
|
tier = "GENERATED_FULL" if matched and bv is not None else "GENERATED_PARTIAL"
|
|
else:
|
|
tier = "INCOMPLETE"
|
|
|
|
from datetime import datetime, timezone
|
|
import socket
|
|
|
|
gen_at = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
|
|
out = {
|
|
"schema_version": 1,
|
|
"report_id": report_id,
|
|
"as_of": as_of,
|
|
"correlation_id": corr,
|
|
"currency": "USD",
|
|
"evidence_tier": tier,
|
|
"evidence_gaps": gaps,
|
|
"ledger": ledger,
|
|
"bank": bank,
|
|
"chain": chain,
|
|
"variance": var,
|
|
"matched": matched,
|
|
"generated_at": gen_at,
|
|
"generator": {
|
|
"script": "scripts/omnl/generate-3way-reconciliation-evidence.sh",
|
|
"argv": argv,
|
|
"host": socket.gethostname(),
|
|
},
|
|
}
|
|
print(json.dumps(out, indent=2))
|
|
PY
|
|
)"
|
|
|
|
echo "$FINAL_JSON" | tee "${OUT_DIR}/3way-${AS_OF}.json" > "${OUT_DIR}/latest-3way-result.json"
|
|
echo "Wrote ${OUT_DIR}/3way-${AS_OF}.json and latest-3way-result.json" >&2
|