diff --git a/.github/workflows/docker-build-brico-bot.yml b/.github/workflows/docker-build-brico-bot.yml index 947419e8..3d6ad215 100644 --- a/.github/workflows/docker-build-brico-bot.yml +++ b/.github/workflows/docker-build-brico-bot.yml @@ -3,12 +3,13 @@ on: workflow_dispatch: push: branches: - - main + - "**" paths: - "backend/brico-bot/**" - "common/bindings/**" - "common/crafts/**" - "common/bitcraft-bindings/**" + - "common/stelo/**" - "package.json" - "package-lock.json" @@ -38,6 +39,14 @@ jobs: password: ${{ secrets.GITHUB_TOKEN }} - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 + - name: Compute image tags + id: meta + uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + tags: | + type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }} + type=ref,event=branch,enable=${{ github.ref != 'refs/heads/main' }} - name: Build and push image id: push uses: docker/build-push-action@f2a1d5e99d037542a71f64918e516c093c6f3fc4 @@ -45,7 +54,7 @@ jobs: context: . file: backend/brico-bot/Dockerfile push: true - tags: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest + tags: ${{ steps.meta.outputs.tags }} cache-from: type=gha cache-to: type=gha,mode=max github-token: "${{ secrets.GITHUB_TOKEN }}" diff --git a/backend/brico-bot/.env.example b/backend/brico-bot/.env.example index b94e342d..357a5c87 100644 --- a/backend/brico-bot/.env.example +++ b/backend/brico-bot/.env.example @@ -10,6 +10,34 @@ #BRICO_APP_HOST=http://127.0.0.1:3000 #BRICO_APP_MODULE=brico-app +# ── Stelo escrow (backs escrowed bounty currencies such as stelo-hex-coin) ─── +# Off unless STELO_ENABLED is set; needs brico-app. STELO_CURRENCIES maps each escrowed currency to +# its Stelo ledger, the Debit escrow wallet the bot holds funds in (the bot must be a member of it +# with Write), and what happens to money owed to a payee with no payout wallet. +#STELO_ENABLED=1 +#STELO_HOST=https://maincloud.spacetimedb.com +#STELO_MODULE=stelofinance +#STELO_CURRENCIES={"stelo-hex-coin":{"ledger":"Hex Coin","escrowAccountId":1,"unclaimed":{"action":"rollover","to":"hex-coin","afterDays":14}}} +# Prefix of every transfer memo/idempotency key; use a different one per deployment. +#STELO_REF_PREFIX=bx +#STELO_PAYOUT_INTERVAL_MS=86400000 +#STELO_MIN_PAYOUT=1 +#STELO_RETRY_BASE_MS=30000 +#STELO_RETRY_MAX_MS=1800000 +# How the bot authenticates to Stelo. +# permanent (default): the state dir holds one long-lived id token, in the file the bot's `stelo` +# connection reads (stelo___auth_token.token, see scripts/dev-stelo.mjs). +# refresh: a human logs in to Stelo's SpacetimeAuth client and puts the id token in +# stelo_id_token.token and the refresh token in stelo_refresh_token.token (state dir). The bot renews +# both every STELO_AUTH_REFRESH_MS, or sooner if the id token expires first. +#STELO_AUTH_MODE=permanent +#STELO_AUTH_AUTHORITY=https://auth.spacetimedb.com/oidc +#STELO_AUTH_CLIENT_ID=client_033wW7fObq5GPPc4ESCFsF +#STELO_AUTH_REFRESH_MS=43200000 +# Discord webhook messaged once when Stelo auth fails (failed renewal, or Stelo rejecting the token) +# and again when it recovers. Unset: failures are only logged. +#STELO_AUTH_ALERT_WEBHOOK= + # ── bridge behaviour ──────────────────────────────────────────────────────── #BRICO_BOT_SNAPSHOT_INTERVAL_MS=1000 #BRICO_BOT_RECONCILE_INTERVAL_MS=300000 diff --git a/backend/brico-bot/README.md b/backend/brico-bot/README.md index ec16a5b1..8e280158 100644 --- a/backend/brico-bot/README.md +++ b/backend/brico-bot/README.md @@ -108,6 +108,22 @@ The identity is stable across restarts because the connection token is persisted `BRICO_BOT_STATE_DIR` (one file per uri+database, mode 0600 — these are credentials; delete a file to get a fresh identity). Running as the database owner also works and needs no registration. +## Stelo escrow against a local Stelo + +Escrowed bounty currencies (`stelo-hex-coin`) move money on Stelo. For local development, publish +Stelo with its `dev-auth` feature (`spacetime publish --build-options='--features dev-auth'`), then +from this directory run: + +```sh +node scripts/dev-stelo.mjs +``` + +It mints the bot's `localhost` identity into the state dir, seeds Stelo as the database owner (the +bot as an app, a "Hex Coin" ledger, an escrow wallet, two payer wallets, a payee wallet) and prints +the `STELO_*` lines for `.env`. It is safe to re-run; set `PAYER_PLAYER_ID` / `PAYEE_PLAYER_ID` to +seed other BitCraft players as the wallets' owners. The bot also needs to be registered as a +`brico-app` service principal (see above), and `brico-app` published with the escrow tables. + ## Layout | Path | What it is | diff --git a/backend/brico-bot/package.json b/backend/brico-bot/package.json index 25d44968..7ed8353b 100644 --- a/backend/brico-bot/package.json +++ b/backend/brico-bot/package.json @@ -10,7 +10,8 @@ "dev": "tsx watch src/main.ts", "test": "node --import tsx --test \"src/**/*.test.ts\"", "typecheck": "tsc --noEmit", - "discord:register-commands": "tsx src/discord/register-commands.ts" + "discord:register-commands": "tsx src/discord/register-commands.ts", + "discord:list-commands": "tsx scripts/list-commands.ts" }, "dependencies": { "@brico/bindings": "*", @@ -20,6 +21,7 @@ "discord-api-types": "^0.38.55", "discord-interactions": "^4.4.0", "jose": "^6.2.0", + "oidc-client-ts": "^3.5.0", "prom-client": "^15.1.3", "sharp": "^0.34.5", "spacetimedb": "2.10.2", diff --git a/backend/brico-bot/scripts/list-commands.ts b/backend/brico-bot/scripts/list-commands.ts new file mode 100644 index 00000000..02aee64f --- /dev/null +++ b/backend/brico-bot/scripts/list-commands.ts @@ -0,0 +1,43 @@ +/** + * list-commands.ts — prints every registered Discord command leaf with its path and arguments + * (`npm run discord:list-commands --workspace @brico/bot`), grouped by scope. + */ +import {ApplicationCommandOptionType} from "discord-api-types/v10"; + +import {commands} from "../src/discord/commands"; +import type {LeafCommand} from "../src/discord/commands/registry"; + +const TYPE_NAMES: Partial> = { + [ApplicationCommandOptionType.String]: "string", + [ApplicationCommandOptionType.Integer]: "integer", + [ApplicationCommandOptionType.Boolean]: "boolean", + [ApplicationCommandOptionType.User]: "user", + [ApplicationCommandOptionType.Channel]: "channel", + [ApplicationCommandOptionType.Role]: "role", + [ApplicationCommandOptionType.Mentionable]: "mentionable", + [ApplicationCommandOptionType.Number]: "number", + [ApplicationCommandOptionType.Attachment]: "attachment", +}; + +function formatArg(option: NonNullable[number]): string { + const choices = "choices" in option ? option.choices : undefined; + const type = choices?.length ? choices.map(c => c.value).join("|") : TYPE_NAMES[option.type] ?? String(option.type); + const text = `${option.name}: ${type}`; + return option.required ? `<${text}>` : `[${text}]`; +} + +function formatLeaf(leaf: LeafCommand): string { + return ["/" + leaf.path.join(" "), ...(leaf.options ?? []).map(formatArg)].join(" "); +} + +function printSection(heading: string | null, leaves: LeafCommand[]): void { + if (leaves.length === 0) return; + if (heading) console.log(`${heading}:`); + for (const leaf of [...leaves].sort((a, b) => a.path.join(" ").localeCompare(b.path.join(" ")))) { + console.log(formatLeaf(leaf)); + } +} + +printSection(null, commands.filter(leaf => leaf.scope === undefined)); +printSection("guild only", commands.filter(leaf => leaf.scope === "guild")); +printSection("dm only", commands.filter(leaf => leaf.scope === "dm")); diff --git a/backend/brico-bot/src/app/app-cache.ts b/backend/brico-bot/src/app/app-cache.ts index 9473127a..ec237198 100644 --- a/backend/brico-bot/src/app/app-cache.ts +++ b/backend/brico-bot/src/app/app-cache.ts @@ -30,6 +30,8 @@ export interface BountyRuleSpec { filter: FilterNode; value: BountyRuleValue; private: boolean; + /** The paying Stelo wallet for an escrowed currency. */ + fundingLinkId: bigint | undefined; } /** @@ -43,6 +45,7 @@ export interface ExistingBountyAssignment { currency: string; assignedByAccountIdentity: Identity; private: boolean; + sticky: boolean; } /** A (payer, payee, currency) ledger key. */ @@ -187,7 +190,7 @@ export function createAppCache(log: Logger): AppCache { const filter = parseRuleFilter(rule); if (!filter) continue; const key = rule.accountIdentity.toHexString(); - const spec: BountyRuleSpec = {id: rule.id, priority: rule.priority, filter, value: rule.value, private: rule.private}; + const spec: BountyRuleSpec = {id: rule.id, priority: rule.priority, filter, value: rule.value, private: rule.private, fundingLinkId: rule.fundingLinkId}; const specs = byAccount.get(key); if (specs) specs.push(spec); else byAccount.set(key, [spec]); diff --git a/backend/brico-bot/src/app/bounty-engine.test.ts b/backend/brico-bot/src/app/bounty-engine.test.ts index 7b6ff282..c6f67452 100644 --- a/backend/brico-bot/src/app/bounty-engine.test.ts +++ b/backend/brico-bot/src/app/bounty-engine.test.ts @@ -8,17 +8,19 @@ import type {DbConnection} from "@brico/bindings/brico-app"; import type {CraftMeta} from "@brico/bindings/prism/types"; import {matchAll} from "@brico/crafts/filter"; import assert from "node:assert/strict"; -import {test} from "node:test"; +import {describe, test} from "node:test"; import {Identity, Timestamp} from "spacetimedb"; import type {RecipeIndex, RecipeStatic} from "../game-data/recipes.ts"; import type {Logger} from "../log.ts"; import {readSnapshot} from "../relay/prism.ts"; import {createRowCache} from "../relay/row-cache.ts"; +import type {FundingDecision} from "../stelo/funding.ts"; import {fakeTable} from "../testing/fake-table.ts"; import {createAppCache} from "./app-cache.ts"; -import {bountyScope, createBountyEngine} from "./bounty-sink.ts"; +import {bountyScope, createBountyEngine, type EscrowFunding} from "./bounty-sink.ts"; import type {BricoAppConnection} from "./connection.ts"; +import {createEscrowCache} from "./escrow-cache.ts"; const quiet: Logger = {debug() {}, info() {}, warn() {}, error() {}, child: () => quiet}; @@ -104,7 +106,7 @@ function fakeModule(feeds: ReturnType, seed: number) { const [into, other] = args.private ? [tables.allPrivateCraftBountyAssignment, tables.allCraftBountyAssignment] : [tables.allCraftBountyAssignment, tables.allPrivateCraftBountyAssignment]; - into.upsert({craftId: args.craftId, ratioNumerator: args.ratioNumerator, ratioDenominator: args.ratioDenominator, currency: args.currency, assignedByAccountIdentity: args.assignedByAccountIdentity, assignedAt: args.assignedAt, updatedAt: args.updatedAt}); + into.upsert({craftId: args.craftId, ratioNumerator: args.ratioNumerator, ratioDenominator: args.ratioDenominator, currency: args.currency, assignedByAccountIdentity: args.assignedByAccountIdentity, assignedAt: args.assignedAt, updatedAt: args.updatedAt, sticky: args.sticky}); other.remove(String(args.craftId)); for (const baseline of args.baselines) { if (!find(tables.allCraftBountyEntitlement, r => r.craftId === args.craftId && r.playerId === baseline.playerId && r.currency === args.currency)) { @@ -162,7 +164,7 @@ function fakeModule(feeds: ReturnType, seed: number) { }; } -function createSide(options: {forceFull: boolean; seed: number}) { +function createSide(options: {forceFull: boolean; seed: number; funding?: EscrowFunding}) { const rowCache = createRowCache(RECIPES); const prism = { craftMeta: fakeTable(r => String(r.entityId), rowCache.craftMeta), @@ -176,7 +178,7 @@ function createSide(options: {forceFull: boolean; seed: number}) { const appCache = createAppCache(quiet); const module = fakeModule(appCache, options.seed); const app = {connection: {connection: module.conn}, isLive: true} as unknown as BricoAppConnection; - const engine = createBountyEngine(app, appCache, quiet); + const engine = createBountyEngine(app, appCache, quiet, options.funding); let started = false; return { @@ -273,8 +275,9 @@ function generator(seed: number) { }, () => { const id = linkId++ % 5n, player = pick(PLAYERS), account = pick(ACCOUNTS), revoked = chance(0.2); + const revokedAt = revoked ? Timestamp.now() : undefined; return side => side.module.tables.allLinkedIntegration.upsert({ - id, accountIdentity: account, provider: "bitcraft-ea2", externalId: player.toString(), revokedAt: revoked ? Timestamp.now() : undefined, + id, accountIdentity: account, provider: "bitcraft-ea2", externalId: player.toString(), revokedAt, }); }, () => { @@ -306,9 +309,9 @@ function generator(seed: number) { const spec = chance(0.4) ? {tag: "ClaimMembership", value: {claimEntityId: pick(CLAIMS.slice(1)), requiredAccess: pick(["member", "build", "officer", "owner"])}} : {tag: "EffortThreshold", value: {allCurrencies: chance(0.5), threshold: BigInt(20 + int(300))}}; - const numerator = BigInt(1 + int(3)), denominator = BigInt(pick([20, 40, 50])); + const numerator = BigInt(1 + int(3)), denominator = BigInt(pick([20, 40, 50])), updatedAt = Timestamp.now(); return (side: Side) => side.module.tables.allLoyaltyRule.upsert({ - id, payerAccountIdentity: payer, currency, spec, bonusRatioNumerator: numerator, bonusRatioDenominator: denominator, updatedAt: Timestamp.now(), + id, payerAccountIdentity: payer, currency, spec, bonusRatioNumerator: numerator, bonusRatioDenominator: denominator, updatedAt, }); }), () => { @@ -316,8 +319,8 @@ function generator(seed: number) { return side => side.module.tables.allLoyaltyRule.remove(id); }, () => { - const id = loyaltyId++ % 4n, payer = pick(ACCOUNTS), player = pick(PLAYERS), currency = pick(CURRENCIES); - return side => side.module.tables.allLoyaltyReward.upsert({id, payerAccountIdentity: payer, payeePlayerId: player, currency, ratioNumerator: 21n, ratioDenominator: 20n, updatedAt: Timestamp.now()}); + const id = loyaltyId++ % 4n, payer = pick(ACCOUNTS), player = pick(PLAYERS), currency = pick(CURRENCIES), updatedAt = Timestamp.now(); + return side => side.module.tables.allLoyaltyReward.upsert({id, payerAccountIdentity: payer, payeePlayerId: player, currency, ratioNumerator: 21n, ratioDenominator: 20n, updatedAt}); }, () => { const id = String(pick([0n, 1n, 2n, 3n])); @@ -444,3 +447,84 @@ test("a closed craft keeps its stored assignment until prism deletes the craft o side.prism.craftMeta.remove("1"); assert.deepEqual(await side.tick(), ["clear 1"]); }); + +test("editing or deleting a rule leaves crafts that already have an assignment alone, and only new crafts follow it", async () => { + const side = createSide({forceFull: false, seed: 1}); + bountiedCraft(side); + assert.ok((await side.tick()).some(write => write.startsWith("assign 1 1/4 hex-coin"))); + + const rule = side.module.tables.allBountyRule.rows.get("rule")!; + side.module.tables.allBountyRule.upsert({...rule, value: {tag: "Flat", value: {ratioNumerator: 1n, ratioDenominator: 2n, currency: "hex-coin"}}}); + assert.deepEqual(await side.tick(), [], "a rate edit does not reach the assigned craft"); + + side.prism.craftMeta.upsert(craftMeta(2n, 200n, 0n, 1, 1, "Active", FRESH)); + assert.ok((await side.tick()).some(write => write.startsWith("assign 2 1/2 hex-coin")), "a new craft gets the edited rate"); + + side.module.tables.allBountyRule.upsert({...rule, deletedAt: Timestamp.now()}); + assert.deepEqual(await side.tick(), [], "deleting the rule clears nothing that is already assigned"); +}); + +test("removing an override sends its craft back to its rules", async () => { + const side = createSide({forceFull: false, seed: 1}); + bountiedCraft(side); + await side.tick(); + side.module.tables.allCraftBountyOverride.upsert({craftId: 1n, accountIdentity: ACCOUNTS[0], ratioNumerator: 1n, ratioDenominator: 2n, currency: "hex-coin", private: false}); + assert.ok((await side.tick()).some(write => write.startsWith("assign 1 1/2 hex-coin"))); + + side.module.tables.allCraftBountyOverride.remove("1"); + assert.ok((await side.tick()).some(write => write.startsWith("assign 1 1/4 hex-coin")), "the rule's rate applies again"); +}); + +describe("escrowed bounties", () => { + function escrowedCraft(decide: () => FundingDecision) { + const gateCalls: string[] = []; + const funding: EscrowFunding = { + escrow: createEscrowCache(), + status: {report() {}, clear() {}, flush() {}}, + gate: { + status: () => ({state: "waiting"}), + prepare(craftId, request, effortCovered) { + gateCalls.push(`${craftId} ${request.ratioNumerator}/${request.ratioDenominator} ${request.fundingLinkId} ${effortCovered}`); + return decide(); + }, + }, + }; + const side = createSide({forceFull: false, seed: 1, funding}); + side.module.tables.allLinkedIntegration.upsert({id: 1n, accountIdentity: ACCOUNTS[0], provider: "bitcraft-ea2", externalId: "200", revokedAt: undefined}); + side.module.tables.allBountyRule.upsert({ + id: "rule", accountIdentity: ACCOUNTS[0], priority: 0, filterJson: JSON.stringify(matchAll()), private: false, fundingLinkId: 9n, + value: {tag: "Flat", value: {ratioNumerator: 1n, ratioDenominator: 4n, currency: "stelo-hex-coin"}}, deletedAt: undefined, + }); + side.prism.craftMeta.upsert(craftMeta(1n, 200n, 0n, 1, 1, "Active", FRESH)); + side.prism.craftContribution.upsert({craftId: 1n, playerId: 201n, contribution: 30}); + return {side, gateCalls}; + } + + test("an unfunded bounty is neither assigned, priced nor shown until the gate says it is ready", async () => { + let decision: FundingDecision = "wait"; + const {side, gateCalls} = escrowedCraft(() => decision); + + assert.deepEqual(await side.tick(), []); + assert.deepEqual(await side.tick(), [], "still waiting"); + assert.equal(side.rowCache.rows.get("1")?.subject.payout, null); + assert.ok(gateCalls.length >= 2, "the gate is asked again every tick"); + assert.match(gateCalls[0]!, /^1 1\/4 9 /, "with the rule's rate and funding wallet"); + + decision = "ready"; + const funded = await side.tick(); + assert.ok(funded.some(write => write.startsWith("assign 1 1/4 stelo-hex-coin")), "assigned once funded"); + await side.tick(); + assert.notEqual(side.rowCache.rows.get("1")?.subject.payout, null); + }); + + test("a craft held for another payer keeps its assignment and nothing is rewritten", async () => { + let decision: FundingDecision = "ready"; + const {side} = escrowedCraft(() => decision); + assert.ok((await side.tick()).some(write => write.startsWith("assign 1 "))); + + decision = "keep"; + const rule = side.module.tables.allBountyRule.rows.get("rule")!; + side.module.tables.allBountyRule.upsert({...rule, value: {tag: "Flat", value: {ratioNumerator: 1n, ratioDenominator: 2n, currency: "stelo-hex-coin"}}}); + assert.deepEqual((await side.tick()).filter(write => write.startsWith("assign")), []); + }); +}); diff --git a/backend/brico-bot/src/app/bounty-sink.test.ts b/backend/brico-bot/src/app/bounty-sink.test.ts index 308b9702..149920d7 100644 --- a/backend/brico-bot/src/app/bounty-sink.test.ts +++ b/backend/brico-bot/src/app/bounty-sink.test.ts @@ -3,7 +3,7 @@ import type {ClaimMember} from "@brico/bindings/prism/types"; import assert from "node:assert/strict"; import {describe, it} from "node:test"; import {Identity, Timestamp} from "spacetimedb"; -import {assignmentBaselines, FRESH_CRAFT_WINDOW_MS, resolveAutomaticBonus} from "./bounty-sink.ts"; +import {assignmentBaselines, currencySwitchBaselines, FRESH_CRAFT_WINDOW_MS, resolveAutomaticBonus} from "./bounty-sink.ts"; const PAYER = new Identity(1n); const PLAYER_ID = 500n; @@ -177,3 +177,16 @@ describe("assignmentBaselines", () => { assert.deepEqual(assignmentBaselines(undefined, firstSeen, firstSeen + 60_000n), []); }); }); + +describe("currencySwitchBaselines", () => { + it("protects all existing effort, however recently the craft was first seen", () => { + assert.deepEqual(currencySwitchBaselines(new Map([[500n, 120n], [501n, 30n]])), [ + {playerId: 500n, effort: 120n}, + {playerId: 501n, effort: 30n}, + ]); + }); + + it("is empty for a craft nobody has contributed to", () => { + assert.deepEqual(currencySwitchBaselines(undefined), []); + }); +}); diff --git a/backend/brico-bot/src/app/bounty-sink.ts b/backend/brico-bot/src/app/bounty-sink.ts index 8e7098d2..b2ecc094 100644 --- a/backend/brico-bot/src/app/bounty-sink.ts +++ b/backend/brico-bot/src/app/bounty-sink.ts @@ -10,6 +10,7 @@ import type {DbConnection} from "@brico/bindings/brico-app"; import type {BountyEntitlementTotal, BountyRuleValue, CraftBountyOverride, LoyaltyRule} from "@brico/bindings/brico-app/types"; import type {ClaimMember} from "@brico/bindings/prism/types"; +import {ESCROWED_BOUNTY_CURRENCIES} from "@brico/crafts/currencies"; import {addRatio, bonusFromMultiplier, reduceRatio} from "@brico/crafts/entitlement"; import type {ClaimAccessFlag, CraftSubject} from "@brico/crafts/filter"; import {claimAccessFlags} from "@brico/crafts/subject"; @@ -21,8 +22,11 @@ import {startStep, timeReducerCall} from "../metrics.ts"; import type {CraftSnapshot} from "../relay/prism.ts"; import type {RowDelta} from "../relay/row-cache.ts"; import type {AssignedBounty, CraftRow} from "../relay/subject.ts"; +import type {FundingStatusSync} from "../stelo/funding-status.ts"; +import type {FundingGate} from "../stelo/funding.ts"; import {type AppCache, type AppDelta, type BountyRuleSpec, type Triple, tripleKey} from "./app-cache.ts"; import type {BricoAppConnection} from "./connection.ts"; +import type {EscrowCache} from "./escrow-cache.ts"; export interface ResolvedBounty { ratioNumerator: bigint; @@ -31,6 +35,13 @@ export interface ResolvedBounty { assignedByAccountIdentity: Identity; /** Whether the matched rule/override was marked private — decides which of the two assignment tables `assign()` writes to. */ private: boolean; + /** The paying Stelo wallet (`linked_integration.id`) named by the matched rule/override; only escrowed currencies use it. */ + fundingLinkId: bigint | undefined; + /** False when it came from the craft's override: removing the override sends the craft back to its rules instead of keeping this. */ + sticky: boolean; + source: "override" | "rule"; + /** The matched `bounty_rule.id`; unknown for a stored (sticky) assignment. */ + ruleId: string | undefined; } /** @@ -50,6 +61,10 @@ function matchRules(subject: CraftSubject, specs: readonly BountyRuleSpec[], acc currency: value.value.currency, assignedByAccountIdentity: accountIdentity, private: spec.private, + fundingLinkId: spec.fundingLinkId, + sticky: true, + source: "rule", + ruleId: spec.id, }; } @@ -62,6 +77,10 @@ function matchRules(subject: CraftSubject, specs: readonly BountyRuleSpec[], acc currency: value.value.currency, assignedByAccountIdentity: accountIdentity, private: spec.private, + fundingLinkId: spec.fundingLinkId, + sticky: true, + source: "rule", + ruleId: spec.id, }; } return null; @@ -70,8 +89,14 @@ function matchRules(subject: CraftSubject, specs: readonly BountyRuleSpec[], acc /** * Resolves one craft's bounty: override first (only if its account actually owns the craft or its * claim — an override belonging to neither is invalid intent and is skipped, falling through to - * rule evaluation exactly as if it didn't exist), then the personal owner's rules, then the claim - * owner's — **the personal owner wins outright** if they have any match at all. + * rule evaluation exactly as if it didn't exist), then the craft's `sticky` assignment, then the + * personal owner's rules, then the claim owner's — **the personal owner wins outright** if they + * have any match at all. + * + * A `sticky` assignment (the craft's stored one) keeps applying while its payer still owns the + * craft or its claim, so editing or deleting a rule only affects crafts that have no assignment yet. + * One that came from an override is not (`sticky: false`): without the override the craft is + * resolved by its rules again. */ export function resolveCraftBounty( craftId: bigint, @@ -82,6 +107,7 @@ export function resolveCraftBounty( claimOwners: ReadonlyMap, rulesByAccount: ReadonlyMap, overridesByCraft: ReadonlyMap, + sticky?: ResolvedBounty, ): ResolvedBounty | null { const ownerAccount = ownerEntityId !== 0n ? playerAccounts.get(ownerEntityId) : undefined; const claimOwnerAccounts = (claimEntityId !== 0n ? claimOwners.get(claimEntityId) ?? [] : []) @@ -99,10 +125,19 @@ export function resolveCraftBounty( currency: override.currency, assignedByAccountIdentity: override.accountIdentity, private: override.private, + fundingLinkId: override.fundingLinkId, + sticky: false, + source: "override", + ruleId: undefined, }; } } + if (sticky?.sticky && ((ownerAccount?.isEqual(sticky.assignedByAccountIdentity) ?? false) + || claimOwnerAccounts.some(identity => identity.isEqual(sticky.assignedByAccountIdentity)))) { + return sticky; + } + if (ownerAccount) { const resolved = matchRules(subject, rulesByAccount.get(ownerAccount.toHexString()) ?? [], ownerAccount); if (resolved) return resolved; @@ -201,11 +236,18 @@ export function assignmentBaselines( return [...byPlayer].map(([playerId, effort]) => ({playerId, effort})); } +/** Baselines for a craft whose bounty moves to another currency: all effort so far, already priced (or deliberately not) under the old one. */ +export function currencySwitchBaselines(byPlayer: ReadonlyMap | undefined): {playerId: bigint; effort: bigint}[] { + return byPlayer === undefined ? [] : [...byPlayer].map(([playerId, effort]) => ({playerId, effort})); +} + function sameBounty(a: ResolvedBounty, b: ResolvedBounty): boolean { return a.ratioNumerator === b.ratioNumerator && a.ratioDenominator === b.ratioDenominator && a.currency === b.currency && a.private === b.private + && a.sticky === b.sticky + && a.fundingLinkId === b.fundingLinkId && a.assignedByAccountIdentity.isEqual(b.assignedByAccountIdentity); } @@ -275,11 +317,22 @@ export interface BountyEngine { updateLoyaltyBonuses(snapshot: CraftSnapshot, scope: BountyScope): void; } -export function createBountyEngine(app: BricoAppConnection, cache: AppCache, log: Logger): BountyEngine { +/** The Stelo escrow the engine consults before assigning an escrowed bounty; absent when Stelo isn't configured. */ +export interface EscrowFunding { + escrow: EscrowCache; + gate: FundingGate; + status: FundingStatusSync; +} + +export function createBountyEngine(app: BricoAppConnection, cache: AppCache, log: Logger, funding?: EscrowFunding): BountyEngine { const scoped = log.child("bounty"); - // Every open craft's resolved bounty, patched by `assign()`. `updateEntitlements` reads it, so - // it always follows `assign()` in the same tick (see `bridge.ts`'s `tick`). + // Every open craft's resolved bounty, patched by `assign()`. It is what the bounty rules and + // overrides say, funded or not. let resolved = new Map(); + // The subset actually assigned (or about to be): what the rest of the bot sees as a craft's + // bounty, and what `updateEntitlements` prices. An escrowed bounty whose hold hasn't settled is + // resolved but not published. Mutated in place, like `resolved`. + let published = new Map(); // `resolved` reflects a pass over every open craft; false until the first one, and after the connection drops. let primed = false; // Crafts whose bounty changed in `assign()`, waiting for `updateEntitlements` to price them. @@ -328,19 +381,37 @@ export function createBountyEngine(app: BricoAppConnection, cache: AppCache, log assign(snapshot, rows, scope) { const conn = liveConnection(); if (!conn) { - const previous = resolved; + const previous = published; resolved = new Map(); + published = new Map(); primed = false; needFullPricing = true; needFullLoyalty = true; - return {bounties: resolved, changed: new Set(previous.keys())}; + return {bounties: published, changed: new Set(previous.keys())}; } const state = cache.state; const everything = !primed || scope.full || scope.app.resolveAll; + // The craft's stored assignment, which keeps applying (see `resolveCraftBounty`). + const stickyFor = (craftId: bigint): ResolvedBounty | undefined => { + const stored = state.assignments.get(craftId); + if (!stored) return undefined; + return { + ratioNumerator: stored.ratioNumerator, + ratioDenominator: stored.ratioDenominator, + currency: stored.currency, + assignedByAccountIdentity: stored.assignedByAccountIdentity, + private: stored.private, + fundingLinkId: funding?.escrow.state.openEscrows.get(craftId)?.fundingLinkId, + sticky: stored.sticky, + source: stored.sticky ? "rule" : "override", + ruleId: undefined, + }; + }; const resolve = (row: CraftRow): ResolvedBounty | undefined => resolveCraftBounty( row.entityId, row.subject, row.ownerEntityId, row.claimEntityId, state.playerAccounts, snapshot.claimOwners, state.bountyRules, state.overrides, + stickyFor(row.entityId), ) ?? undefined; let stop = startStep("assign_resolve_loop"); @@ -360,6 +431,7 @@ export function createBountyEngine(app: BricoAppConnection, cache: AppCache, log } for (const craftId of previous.keys()) if (!resolved.has(craftId)) changed.add(craftId); for (const craftId of state.assignments.keys()) candidates.add(craftId); + for (const craftId of published.keys()) candidates.add(craftId); primed = true; } else { const touched = new Set(); @@ -370,8 +442,11 @@ export function createBountyEngine(app: BricoAppConnection, cache: AppCache, log if (bounty) resolved.set(craftId, bounty); else resolved.delete(craftId); }; - for (const row of scope.changed) update(row.entityId, resolve(row)); - for (const craftId of scope.app.overrideCrafts) { + // A craft's stored assignment is part of what resolves its bounty (it is sticky), so a change to it re-resolves the craft. + const toResolve = new Set(scope.app.overrideCrafts); + for (const row of scope.changed) toResolve.add(row.entityId); + for (const craftId of scope.app.assignmentCrafts) toResolve.add(craftId); + for (const craftId of toResolve) { const row = scope.rowOf(craftId); update(craftId, row ? resolve(row) : undefined); } @@ -389,20 +464,62 @@ export function createBountyEngine(app: BricoAppConnection, cache: AppCache, log for (const craftId of changed) unpriced.add(craftId); stop(); + const publish = (craftId: bigint, bounty: ResolvedBounty | undefined): void => { + const before = published.get(craftId); + if (bounty) published.set(craftId, bounty); + else published.delete(craftId); + if (!!before !== !!bounty || (before && bounty && !sameBounty(before, bounty))) { + changed.add(craftId); + unpriced.add(craftId); + } + }; + stop = startStep("assign_write_loop"); for (const craftId of candidates) { const bounty = resolved.get(craftId); const previous = state.assignments.get(craftId); if (bounty) { + const row = scope.rowOf(craftId); + let baselines: {playerId: bigint; effort: bigint}[] = []; + if (row && previous === undefined) { + baselines = assignmentBaselines(snapshot.contributions.get(craftId), row.firstSeenMs, BigInt(Date.now())); + } else if (row && previous !== undefined && previous.currency !== bounty.currency) { + baselines = currencySwitchBaselines(snapshot.contributions.get(craftId)); + } + + if (funding && ESCROWED_BOUNTY_CURRENCIES.includes(bounty.currency)) { + let effortCovered = 0n; + if (row) { + effortCovered = BigInt(row.subject.effortTotal) - baselines.reduce((sum, b) => sum + b.effort, 0n); + } + const request = { + ratioNumerator: bounty.ratioNumerator, + ratioDenominator: bounty.ratioDenominator, + currency: bounty.currency, + payer: bounty.assignedByAccountIdentity, + fundingLinkId: bounty.fundingLinkId, + }; + const decision = funding.gate.prepare(craftId, request, effortCovered > 0n ? effortCovered : 0n); + funding.status.report(craftId, {...request, source: bounty.source, ruleId: bounty.ruleId}, funding.gate.status(craftId, request)); + if (decision !== "ready") { + // Not (yet) fundable at this rate: the stored assignment, if any, stays as it is. + publish(craftId, stickyFor(craftId)); + if (decision === "wait") retryAssign.add(craftId); + continue; + } + } + else funding?.status.clear(craftId); + publish(craftId, bounty); + const unchanged = previous !== undefined && previous.ratioNumerator === bounty.ratioNumerator && previous.ratioDenominator === bounty.ratioDenominator && previous.currency === bounty.currency && previous.assignedByAccountIdentity.isEqual(bounty.assignedByAccountIdentity) - && previous.private === bounty.private; + && previous.private === bounty.private + && previous.sticky === bounty.sticky; if (unchanged) continue; - const row = scope.rowOf(craftId); timeReducerCall("assign_craft_bounty", conn.reducers.assignCraftBounty({ craftId, ratioNumerator: bounty.ratioNumerator, @@ -412,15 +529,18 @@ export function createBountyEngine(app: BricoAppConnection, cache: AppCache, log private: bounty.private, assignedAt: Timestamp.now(), updatedAt: Timestamp.now(), - // Only a craft with no assignment as of last tick needs baselines. - baselines: previous === undefined && row - ? assignmentBaselines(snapshot.contributions.get(craftId), row.firstSeenMs, BigInt(Date.now())) - : [], + // Only a craft with no assignment as of last tick, or one switching currency, needs baselines. + baselines, + sticky: bounty.sticky, })).catch(cause => { retryAssign.add(craftId); scoped.error("assign_craft_bounty failed", {craftId: craftId.toString(), error: cause instanceof Error ? cause.message : String(cause)}); }); - } else if (previous) { + } else { + funding?.status.clear(craftId); + publish(craftId, undefined); + } + if (!bounty && previous) { // A stored assignment with no resolved bounty is cleared if its craft is open (the // bounty went away) or gone from `craft_meta` entirely (past the 24-hour // Claimed/Removed tail — see `isOpen` in prism.ts); one on a closed craft still in @@ -434,7 +554,7 @@ export function createBountyEngine(app: BricoAppConnection, cache: AppCache, log } stop(); - return {bounties: resolved, changed}; + return {bounties: published, changed}; }, updateEntitlements(snapshot, scope) { @@ -448,7 +568,7 @@ export function createBountyEngine(app: BricoAppConnection, cache: AppCache, log let crafts: Iterable; if (full) { - crafts = [...resolved.keys()]; + crafts = [...published.keys()]; } else { crafts = new Set([...scope.contributionCrafts, ...unpriced, ...retryEntitlement]); } @@ -457,7 +577,7 @@ export function createBountyEngine(app: BricoAppConnection, cache: AppCache, log const stop = startStep("ent_loop"); for (const craftId of crafts) { - const bounty = resolved.get(craftId); + const bounty = published.get(craftId); const byPlayer = snapshot.contributions.get(craftId); if (!bounty || !byPlayer) continue; for (const [playerId, effort] of byPlayer) { @@ -499,6 +619,8 @@ export function createBountyEngine(app: BricoAppConnection, cache: AppCache, log effort, ratioNumerator: effectiveRatio.numerator, ratioDenominator: effectiveRatio.denominator, + baseRatioNumerator: bounty.ratioNumerator, + baseRatioDenominator: bounty.ratioDenominator, updatedAt: Timestamp.now(), })).catch(cause => { retryEntitlement.add(craftId); diff --git a/backend/brico-bot/src/app/connection.ts b/backend/brico-bot/src/app/connection.ts index 6d126c54..2b253f8f 100644 --- a/backend/brico-bot/src/app/connection.ts +++ b/backend/brico-bot/src/app/connection.ts @@ -10,6 +10,7 @@ import {appDriftTotal, startStep} from "../metrics.ts"; import {createSupervisedConnection, type SupervisedConnection, type SupervisorOptions} from "../spacetime/connection.ts"; import {attachFeed} from "../spacetime/feed.ts"; import {type AppCache, findAppDrift} from "./app-cache.ts"; +import type {EscrowCache} from "./escrow-cache.ts"; interface AppTable { /** Generated query builder for the subscription. */ @@ -182,6 +183,132 @@ const APP_TABLES: Record = { }, count: conn => conn.db.allDiscordGuildInstall.count(), }, + all_craft_bounty_escrow: { + query: tables.allCraftBountyEscrow, + listen: (conn, onChange) => { + conn.db.allCraftBountyEscrow.onInsert(onChange); + conn.db.allCraftBountyEscrow.onDelete(onChange); + conn.db.allCraftBountyEscrow.onUpdate(onChange); + }, + count: conn => conn.db.allCraftBountyEscrow.count(), + }, + all_bounty_escrow_wallet: { + query: tables.allBountyEscrowWallet, + listen: (conn, onChange) => { + conn.db.allBountyEscrowWallet.onInsert(onChange); + conn.db.allBountyEscrowWallet.onDelete(onChange); + conn.db.allBountyEscrowWallet.onUpdate(onChange); + }, + count: conn => conn.db.allBountyEscrowWallet.count(), + }, + all_bounty_escrow_pool: { + query: tables.allBountyEscrowPool, + listen: (conn, onChange) => { + conn.db.allBountyEscrowPool.onInsert(onChange); + conn.db.allBountyEscrowPool.onDelete(onChange); + conn.db.allBountyEscrowPool.onUpdate(onChange); + }, + count: conn => conn.db.allBountyEscrowPool.count(), + }, + all_bounty_escrow_journal: { + query: tables.allBountyEscrowJournal, + listen: (conn, onChange) => { + conn.db.allBountyEscrowJournal.onInsert(onChange); + conn.db.allBountyEscrowJournal.onDelete(onChange); + conn.db.allBountyEscrowJournal.onUpdate(onChange); + }, + count: conn => conn.db.allBountyEscrowJournal.count(), + }, + all_stelo_transfer: { + query: tables.allSteloTransfer, + listen: (conn, onChange) => { + conn.db.allSteloTransfer.onInsert(onChange); + conn.db.allSteloTransfer.onDelete(onChange); + conn.db.allSteloTransfer.onUpdate(onChange); + }, + count: conn => conn.db.allSteloTransfer.count(), + }, + all_stelo_wallet: { + query: tables.allSteloWallet, + listen: (conn, onChange) => { + conn.db.allSteloWallet.onInsert(onChange); + conn.db.allSteloWallet.onDelete(onChange); + conn.db.allSteloWallet.onUpdate(onChange); + }, + count: conn => conn.db.allSteloWallet.count(), + }, + all_stelo_receiving_wallet: { + query: tables.allSteloReceivingWallet, + listen: (conn, onChange) => { + conn.db.allSteloReceivingWallet.onInsert(onChange); + conn.db.allSteloReceivingWallet.onDelete(onChange); + conn.db.allSteloReceivingWallet.onUpdate(onChange); + }, + count: conn => conn.db.allSteloReceivingWallet.count(), + }, + all_integration_link_request: { + query: tables.allIntegrationLinkRequest, + listen: (conn, onChange) => { + conn.db.allIntegrationLinkRequest.onInsert(onChange); + conn.db.allIntegrationLinkRequest.onDelete(onChange); + conn.db.allIntegrationLinkRequest.onUpdate(onChange); + }, + count: conn => conn.db.allIntegrationLinkRequest.count(), + }, + all_craft_bounty_funding: { + query: tables.allCraftBountyFunding, + listen: (conn, onChange) => { + conn.db.allCraftBountyFunding.onInsert(onChange); + conn.db.allCraftBountyFunding.onDelete(onChange); + conn.db.allCraftBountyFunding.onUpdate(onChange); + }, + count: conn => conn.db.allCraftBountyFunding.count(), + }, + all_stelo_wallet_status: { + query: tables.allSteloWalletStatus, + listen: (conn, onChange) => { + conn.db.allSteloWalletStatus.onInsert(onChange); + conn.db.allSteloWalletStatus.onDelete(onChange); + conn.db.allSteloWalletStatus.onUpdate(onChange); + }, + count: conn => conn.db.allSteloWalletStatus.count(), + }, + all_funding_notify_trigger: { + query: tables.allFundingNotifyTrigger, + listen: (conn, onChange) => { + conn.db.allFundingNotifyTrigger.onInsert(onChange); + conn.db.allFundingNotifyTrigger.onDelete(onChange); + conn.db.allFundingNotifyTrigger.onUpdate(onChange); + }, + count: conn => conn.db.allFundingNotifyTrigger.count(), + }, + all_discord_funding_notify_target: { + query: tables.allDiscordFundingNotifyTarget, + listen: (conn, onChange) => { + conn.db.allDiscordFundingNotifyTarget.onInsert(onChange); + conn.db.allDiscordFundingNotifyTarget.onDelete(onChange); + conn.db.allDiscordFundingNotifyTarget.onUpdate(onChange); + }, + count: conn => conn.db.allDiscordFundingNotifyTarget.count(), + }, + stelo_currency_policy: { + query: tables.steloCurrencyPolicy, + listen: (conn, onChange) => { + conn.db.steloCurrencyPolicy.onInsert(onChange); + conn.db.steloCurrencyPolicy.onDelete(onChange); + conn.db.steloCurrencyPolicy.onUpdate(onChange); + }, + count: conn => conn.db.steloCurrencyPolicy.count(), + }, + stelo_payout_schedule: { + query: tables.steloPayoutSchedule, + listen: (conn, onChange) => { + conn.db.steloPayoutSchedule.onInsert(onChange); + conn.db.steloPayoutSchedule.onDelete(onChange); + conn.db.steloPayoutSchedule.onUpdate(onChange); + }, + count: conn => conn.db.steloPayoutSchedule.count(), + }, }; export interface BricoAppConnection { @@ -201,6 +328,8 @@ export interface BricoAppOptions extends SupervisorOptions { onRowsChanged(): void; /** Mirror of the bounty tables: fed from row callbacks, and replaced by a full read each time the initial subscription applies. */ cache: AppCache; + /** Mirror of the escrow, transfer-intent and Stelo wallet tables, fed the same way as `cache`. */ + escrow: EscrowCache; /** How often to compare the cache against a full table read, adopting the full read on drift. 0 disables. */ reconcileIntervalMs: number; /** Called each time the initial subscription is applied (including after a reconnect), not on row changes. */ @@ -223,7 +352,7 @@ export function startBricoAppConnection(options: BricoAppOptions): BricoAppConne let identityHex: string | null = null; let current: DbConnection | null = null; - const {cache} = options; + const {cache, escrow} = options; const connection = createSupervisedConnection( { @@ -261,12 +390,28 @@ export function startBricoAppConnection(options: BricoAppOptions): BricoAppConne attachFeed(conn.db.allLoyaltyRule, cache.loyaltyRule, accepts); attachFeed(conn.db.allLoyaltyBonusTotal, cache.loyaltyBonusTotal, accepts); attachFeed(conn.db.allBountyEntitlementTotal, cache.bountyEntitlementTotal, accepts); + attachFeed(conn.db.allCraftBountyEscrow, escrow.craftBountyEscrow, accepts); + attachFeed(conn.db.allBountyEscrowWallet, escrow.escrowWallet, accepts); + attachFeed(conn.db.allBountyEscrowPool, escrow.escrowPool, accepts); + attachFeed(conn.db.allBountyEscrowJournal, escrow.escrowJournal, accepts); + attachFeed(conn.db.allSteloTransfer, escrow.steloTransfer, accepts); + attachFeed(conn.db.allLinkedIntegration, escrow.linkedIntegration, accepts); + attachFeed(conn.db.allSteloWallet, escrow.steloWallet, accepts); + attachFeed(conn.db.allSteloReceivingWallet, escrow.steloReceivingWallet, accepts); + attachFeed(conn.db.allIntegrationLinkRequest, escrow.integrationLinkRequest, accepts); + attachFeed(conn.db.allCraftBountyFunding, escrow.craftBountyFunding, accepts); + attachFeed(conn.db.allSteloWalletStatus, escrow.steloWalletStatus, accepts); + attachFeed(conn.db.allFundingNotifyTrigger, escrow.fundingNotifyTrigger, accepts); + attachFeed(conn.db.allDiscordFundingNotifyTarget, escrow.discordFundingNotifyTarget, accepts); + attachFeed(conn.db.steloCurrencyPolicy, escrow.currencyPolicy, accepts); + attachFeed(conn.db.steloPayoutSchedule, escrow.payoutSchedule, accepts); let subscription: SubscriptionHandle | null = conn .subscriptionBuilder() .onApplied(() => { if (!ctx.isCurrent()) return; cache.load(conn); + escrow.load(conn); loaded = true; ctx.setLive(); options.onRowsChanged(); diff --git a/backend/brico-bot/src/app/escrow-cache.ts b/backend/brico-bot/src/app/escrow-cache.ts new file mode 100644 index 00000000..a06855f6 --- /dev/null +++ b/backend/brico-bot/src/app/escrow-cache.ts @@ -0,0 +1,212 @@ +/** + * escrow-cache.ts — the `brico-app` escrow, transfer-intent and Stelo wallet tables, mirrored from + * row callbacks. Kept apart from `AppCache` because only the Stelo step reads it; `version` tells + * that step whether anything moved since it last ran. + */ +import type {DbConnection} from "@brico/bindings/brico-app"; +import type { + BountyEscrowJournal, + BountyEscrowPool, + BountyEscrowWallet, + CraftBountyEscrow, + CraftBountyFunding, + DiscordFundingNotifyTarget, + FundingNotifyTrigger, + IntegrationLinkRequest, + LinkedIntegration, + SteloCurrencyPolicy, + SteloPayoutSchedule, + SteloReceivingWallet, + SteloTransfer, + SteloWallet, + SteloWalletStatus, +} from "@brico/bindings/brico-app/types"; +import type {Identity} from "spacetimedb"; + +import {keyedFeed, type TableFeed} from "../spacetime/feed.ts"; + +export const walletKey = (payer: Identity, currency: string, fundingAccountId: bigint) => + `${payer.toHexString()}:${currency}:${fundingAccountId}`; +export const receivingKey = (account: Identity, currency: string) => `${account.toHexString()}:${currency}`; +export const poolKey = (payer: Identity, payee: bigint, currency: string, fundingAccountId: bigint) => + `${payer.toHexString()}:${payee}:${currency}:${fundingAccountId}`; + +export interface EscrowState { + /** The open (not closed) escrow hold per craft. */ + readonly openEscrows: ReadonlyMap; + /** Refund-due rows by `walletKey`. */ + readonly escrowWallets: ReadonlyMap; + /** Entitlement pools by `poolKey`. */ + readonly pools: ReadonlyMap; + /** Σ `bounty_escrow_journal.amount` per currency — what the escrow wallet should hold. */ + readonly journalBalance: ReadonlyMap; + /** Every Stelo transfer intent by id. */ + readonly transfers: ReadonlyMap; + /** Active `stelo` links by link id. */ + readonly steloLinks: ReadonlyMap; + readonly steloWallets: ReadonlyMap; + /** By `receivingKey`. */ + readonly receivingWallets: ReadonlyMap; + /** Pending `stelo` link requests by code. */ + readonly linkRequests: ReadonlyMap; + readonly schedule: SteloPayoutSchedule | undefined; + /** The published unclaimed-money policy per currency. */ + readonly policies: ReadonlyMap; + /** The funding-status row per craft. */ + readonly fundingRows: ReadonlyMap; + /** The last `canPull` written per wallet link id. */ + readonly walletStatuses: ReadonlyMap; + /** Live (not tombstoned) funding-alert triggers by id. */ + readonly fundingTriggers: ReadonlyMap; + /** Live Discord funding-alert targets by id. */ + readonly fundingTargets: ReadonlyMap; +} + +export interface EscrowCache { + readonly craftBountyEscrow: TableFeed; + readonly escrowWallet: TableFeed; + readonly escrowPool: TableFeed; + readonly escrowJournal: TableFeed; + readonly steloTransfer: TableFeed; + readonly linkedIntegration: TableFeed; + readonly steloWallet: TableFeed; + readonly steloReceivingWallet: TableFeed; + readonly integrationLinkRequest: TableFeed; + readonly payoutSchedule: TableFeed; + readonly currencyPolicy: TableFeed; + readonly craftBountyFunding: TableFeed; + readonly steloWalletStatus: TableFeed; + readonly fundingNotifyTrigger: TableFeed; + readonly discordFundingNotifyTarget: TableFeed; + load(conn: DbConnection): void; + readonly state: EscrowState; + /** Incremented on every change. */ + readonly version: number; +} + +export function createEscrowCache(): EscrowCache { + const openEscrows = new Map(); + const escrowWallets = new Map(); + const pools = new Map(); + const journalBalance = new Map(); + const transfers = new Map(); + const steloLinks = new Map(); + const steloWallets = new Map(); + const receivingWallets = new Map(); + const linkRequests = new Map(); + const fundingRows = new Map(); + const walletStatuses = new Map(); + const fundingTriggers = new Map(); + const fundingTargets = new Map(); + const policies = new Map(); + let schedule: SteloPayoutSchedule | undefined; + let version = 0; + const bump = () => void (version += 1); + + const setEscrow = (row: CraftBountyEscrow) => { + if (row.closedAt === undefined) openEscrows.set(row.craftId, row); + else if (openEscrows.get(row.craftId)?.id === row.id) openEscrows.delete(row.craftId); + bump(); + }; + const removeEscrow = (row: CraftBountyEscrow) => { + if (openEscrows.get(row.craftId)?.id === row.id) openEscrows.delete(row.craftId); + bump(); + }; + const setLink = (row: LinkedIntegration) => { + if (row.provider !== "stelo" || row.revokedAt !== undefined) steloLinks.delete(row.id); + else steloLinks.set(row.id, row); + bump(); + }; + const setRequest = (row: IntegrationLinkRequest) => { + if (row.provider === "stelo") linkRequests.set(row.code, row); + bump(); + }; + const poolOf = (row: BountyEscrowPool) => poolKey(row.payerAccountIdentity, row.payeePlayerId, row.currency, row.fundingAccountId); + const walletOf = (row: BountyEscrowWallet) => walletKey(row.payerAccountIdentity, row.currency, row.fundingAccountId); + const receivingOf = (row: SteloReceivingWallet) => receivingKey(row.accountIdentity, row.currency); + + const setTrigger = (row: FundingNotifyTrigger) => { + if (row.deletedAt === undefined) fundingTriggers.set(row.id, row); + else fundingTriggers.delete(row.id); + bump(); + }; + const setTarget = (row: DiscordFundingNotifyTarget) => { + if (row.deletedAt === undefined) fundingTargets.set(row.id, row); + else fundingTargets.delete(row.id); + bump(); + }; + + const cache: EscrowCache = { + craftBountyEscrow: {insert: setEscrow, update: (_before, after) => setEscrow(after), delete: removeEscrow}, + escrowWallet: keyedFeed(walletOf, row => (escrowWallets.set(walletOf(row), row), bump()), row => (escrowWallets.delete(walletOf(row)), bump())), + escrowPool: keyedFeed(poolOf, row => (pools.set(poolOf(row), row), bump()), row => (pools.delete(poolOf(row)), bump())), + // Journal rows are insert-only; the running balance is all that is kept. + escrowJournal: { + insert: row => (journalBalance.set(row.currency, (journalBalance.get(row.currency) ?? 0n) + row.amount), bump()), + update: () => {}, + delete: () => {}, + }, + steloTransfer: keyedFeed(row => row.id, row => (transfers.set(row.id, row), bump()), row => (transfers.delete(row.id), bump())), + linkedIntegration: keyedFeed(row => row.id, setLink, row => (steloLinks.delete(row.id), bump())), + steloWallet: keyedFeed(row => row.linkId, row => (steloWallets.set(row.linkId, row), bump()), row => (steloWallets.delete(row.linkId), bump())), + steloReceivingWallet: keyedFeed(receivingOf, row => (receivingWallets.set(receivingOf(row), row), bump()), row => (receivingWallets.delete(receivingOf(row)), bump())), + integrationLinkRequest: keyedFeed(row => row.code, setRequest, row => (linkRequests.delete(row.code), bump())), + craftBountyFunding: keyedFeed(row => row.craftId, row => (fundingRows.set(row.craftId, row), bump()), row => (fundingRows.delete(row.craftId), bump())), + steloWalletStatus: keyedFeed(row => row.linkId, row => (walletStatuses.set(row.linkId, row), bump()), row => (walletStatuses.delete(row.linkId), bump())), + fundingNotifyTrigger: keyedFeed(row => row.id, setTrigger, row => (fundingTriggers.delete(row.id), bump())), + discordFundingNotifyTarget: keyedFeed(row => row.id, setTarget, row => (fundingTargets.delete(row.id), bump())), + currencyPolicy: keyedFeed(row => row.currency, row => (policies.set(row.currency, row), bump()), row => (policies.delete(row.currency), bump())), + payoutSchedule: { + insert: row => ((schedule = row), bump()), + update: (_before, after) => ((schedule = after), bump()), + delete: () => ((schedule = undefined), bump()), + }, + + load(conn) { + for (const map of [openEscrows, escrowWallets, pools, journalBalance, transfers, steloLinks, steloWallets, receivingWallets, linkRequests, fundingRows, walletStatuses, fundingTriggers, fundingTargets, policies] as Map[]) { + map.clear(); + } + const {db} = conn; + for (const row of db.allCraftBountyEscrow.iter()) setEscrow(row); + for (const row of db.allBountyEscrowWallet.iter()) escrowWallets.set(walletOf(row), row); + for (const row of db.allBountyEscrowPool.iter()) pools.set(poolOf(row), row); + for (const row of db.allBountyEscrowJournal.iter()) cache.escrowJournal.insert(row); + for (const row of db.allSteloTransfer.iter()) transfers.set(row.id, row); + for (const row of db.allLinkedIntegration.iter()) setLink(row); + for (const row of db.allSteloWallet.iter()) steloWallets.set(row.linkId, row); + for (const row of db.allSteloReceivingWallet.iter()) receivingWallets.set(receivingOf(row), row); + for (const row of db.allIntegrationLinkRequest.iter()) setRequest(row); + for (const row of db.allCraftBountyFunding.iter()) fundingRows.set(row.craftId, row); + for (const row of db.allSteloWalletStatus.iter()) walletStatuses.set(row.linkId, row); + for (const row of db.allFundingNotifyTrigger.iter()) setTrigger(row); + for (const row of db.allDiscordFundingNotifyTarget.iter()) setTarget(row); + for (const row of db.steloCurrencyPolicy.iter()) policies.set(row.currency, row); + schedule = [...db.steloPayoutSchedule.iter()][0]; + bump(); + }, + + state: { + openEscrows, + escrowWallets, + pools, + journalBalance, + transfers, + steloLinks, + steloWallets, + receivingWallets, + linkRequests, + fundingRows, + walletStatuses, + fundingTriggers, + fundingTargets, + policies, + get schedule() { + return schedule; + }, + }, + get version() { + return version; + }, + }; + return cache; +} diff --git a/backend/brico-bot/src/bridge.ts b/backend/brico-bot/src/bridge.ts index 4c44aa1f..2c536b1b 100644 --- a/backend/brico-bot/src/bridge.ts +++ b/backend/brico-bot/src/bridge.ts @@ -85,6 +85,8 @@ export interface BridgeOptions { * evaluated in the same tick — see the ordering note in `tick`. */ bounty?: BountyEngine; + /** The Stelo escrow step, run after the bounty steps each tick (see `createSteloManager`). */ + stelo?: {step(hasEnded: (craftId: bigint) => boolean): void}; /** The `brico-app` mirror `bounty` reads; its changes are drained each tick. Without one, `bounty` sees no `brico-app` changes. */ appCache?: AppCache; /** Called once per tick with every open craft's resolved row (post-bounty-assignment), before watch matching. */ @@ -157,6 +159,7 @@ export function createBridge(options: BridgeOptions): Bridge { entitlementTimer(); options.bounty.updateLoyaltyBonuses(cache.snapshot, scope); + options.stelo?.step(craftId => cache.snapshot.allCraftIds.has(craftId) && !cache.baseRows.has(craftId.toString())); stop = startStep("cache_assignments"); cache.setAssignments(assigned.bounties, assigned.changed); diff --git a/backend/brico-bot/src/config.ts b/backend/brico-bot/src/config.ts index 96555d34..00930639 100644 --- a/backend/brico-bot/src/config.ts +++ b/backend/brico-bot/src/config.ts @@ -5,6 +5,7 @@ * inlined at build time by a bundler this process never runs, and reusing them would imply the * frontend's `.env.local` configures the bot (it doesn't). */ +import {ESCROWED_BOUNTY_CURRENCIES, MANUALLY_RECORDED_BOUNTY_CURRENCIES} from "@brico/crafts/currencies"; import {existsSync} from "node:fs"; import path from "node:path"; import {fileURLToPath} from "node:url"; @@ -65,6 +66,47 @@ export interface DiscordBotConfig { installClientSecret: string | null; } +/** What happens to escrow-backed money owed to a payee with no routable payout wallet, after `afterDays`. */ +export type UnclaimedPolicy = + | {action: "rollover"; to: string; afterDays: number} + | {action: "refund"; afterDays: number}; + +/** One escrowed bounty currency's Stelo backing. */ +export interface SteloCurrencyConfig { + /** Name of the Stelo ledger this currency lives on. */ + ledger: string; + /** Stelo account id of the Debit escrow wallet the bot holds this currency's funds in. */ + escrowAccountId: bigint; + unclaimed: UnclaimedPolicy; +} + +/** + * How the bot authenticates to Stelo. `permanent`: the state dir holds one long-lived id token. + * `refresh`: it holds a short-lived id token and a one-time-use refresh token, which the bot + * renews against `authority` (a human does the interactive login and seeds both files). + */ +export type SteloAuthConfig = + | {mode: "permanent"} + | {mode: "refresh"; authority: string; clientId: string; refreshIntervalMs: number}; + +export interface SteloConfig { + target: SpacetimeTarget; + auth: SteloAuthConfig; + /** Discord webhook URL notified when Stelo auth fails and a human must re-authenticate; `null` only logs. */ + authAlertWebhook: string | null; + /** Keyed by escrowed bounty currency id. */ + currencies: Record; + /** Prefix of every transfer ref (memo and idempotency key); distinct per deployment. */ + refPrefix: string; + /** Sweep cadence for payouts to payees. */ + payoutIntervalMs: number; + /** Smallest amount (whole units) a sweep will pay out. */ + minPayout: number; + /** Funding-failure backoff: first delay, doubling up to the max. */ + retryBaseMs: number; + retryMaxMs: number; +} + export interface BotConfig { /** prism's `relay-module` — the read-mirror of BitCraft. Always required. */ prism: SpacetimeTarget; @@ -72,6 +114,8 @@ export interface BotConfig { * brico's own module. `null` disables the second connection entirely. */ bricoApp: SpacetimeTarget | null; + /** Stelo escrow for `stelo-hex-coin`-style currencies. `null` disables the third connection and every funding/payout step. */ + stelo: SteloConfig | null; /** How long row changes are coalesced before changed crafts are re-matched against the watches. */ snapshotIntervalMs: number; /** How often the incremental row cache is compared against a full table read. 0 disables. */ @@ -187,6 +231,73 @@ function discordOAuthConfig(httpBaseUrl: string): DiscordOAuthConfig | null { }; } +/** Parses `STELO_CURRENCIES`; every key must be an escrowed bounty currency and every rollover target a manually recorded one. */ +export function parseSteloCurrencies(raw: string): Record { + const parsed: unknown = JSON.parse(raw); + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { + throw new Error("STELO_CURRENCIES must be a JSON object keyed by currency id"); + } + const result: Record = {}; + for (const [currency, value] of Object.entries(parsed as Record)) { + if (!ESCROWED_BOUNTY_CURRENCIES.includes(currency)) { + throw new Error(`STELO_CURRENCIES: "${currency}" is not an escrowed bounty currency`); + } + if (typeof value?.ledger !== "string" || value.ledger === "") throw new Error(`STELO_CURRENCIES.${currency}.ledger is required`); + if (!/^\d+$/.test(String(value.escrowAccountId ?? ""))) throw new Error(`STELO_CURRENCIES.${currency}.escrowAccountId must be an account id`); + const unclaimed = value.unclaimed; + const afterDays = Number(unclaimed?.afterDays); + if (!Number.isFinite(afterDays) || afterDays < 0) throw new Error(`STELO_CURRENCIES.${currency}.unclaimed.afterDays must be a non-negative number`); + let policy: UnclaimedPolicy; + if (unclaimed.action === "rollover") { + if (!MANUALLY_RECORDED_BOUNTY_CURRENCIES.includes(unclaimed.to)) { + throw new Error(`STELO_CURRENCIES.${currency}.unclaimed.to must be a manually recorded currency`); + } + policy = {action: "rollover", to: unclaimed.to, afterDays}; + } else if (unclaimed.action === "refund") { + policy = {action: "refund", afterDays}; + } else { + throw new Error(`STELO_CURRENCIES.${currency}.unclaimed.action must be "rollover" or "refund"`); + } + result[currency] = {ledger: value.ledger, escrowAccountId: BigInt(String(value.escrowAccountId)), unclaimed: policy}; + } + return result; +} + +function steloAuthConfig(): SteloAuthConfig { + const mode = str("STELO_AUTH_MODE", "permanent"); + if (mode === "permanent") return {mode}; + if (mode !== "refresh") throw new Error(`STELO_AUTH_MODE must be permanent|refresh, got ${JSON.stringify(mode)}`); + return { + mode, + authority: str("STELO_AUTH_AUTHORITY", "https://auth.spacetimedb.com/oidc"), + clientId: str("STELO_AUTH_CLIENT_ID", "client_033wW7fObq5GPPc4ESCFsF"), + refreshIntervalMs: int("STELO_AUTH_REFRESH_MS", 12 * 60 * 60 * 1000), + }; +} + +function steloConfig(): SteloConfig | null { + if (!bool("STELO_ENABLED", false)) return null; + const currencies = parseSteloCurrencies(optionalStr("STELO_CURRENCIES") ?? "{}"); + if (Object.keys(currencies).length === 0) throw new Error("STELO_ENABLED needs STELO_CURRENCIES to name at least one currency"); + const refPrefix = str("STELO_REF_PREFIX", "bx"); + if (!/^[a-z0-9]{1,8}$/i.test(refPrefix)) throw new Error("STELO_REF_PREFIX must be 1-8 letters or digits"); + return { + target: { + label: "stelo", + uri: str("STELO_HOST", "https://maincloud.spacetimedb.com"), + database: str("STELO_MODULE", "stelofinance"), + }, + auth: steloAuthConfig(), + authAlertWebhook: optionalStr("STELO_AUTH_ALERT_WEBHOOK"), + currencies, + refPrefix, + payoutIntervalMs: int("STELO_PAYOUT_INTERVAL_MS", 24 * 60 * 60 * 1000), + minPayout: int("STELO_MIN_PAYOUT", 1), + retryBaseMs: int("STELO_RETRY_BASE_MS", 30_000), + retryMaxMs: int("STELO_RETRY_MAX_MS", 30 * 60 * 1000), + }; +} + export function loadConfig(): BotConfig { const stateDir = path.resolve(PACKAGE_ROOT, str("BRICO_BOT_STATE_DIR", ".state")); // Defaults are container-friendly (bind every interface; the real public URL this deploys @@ -217,6 +328,7 @@ export function loadConfig(): BotConfig { return { prism, bricoApp, + stelo: steloConfig(), snapshotIntervalMs: int("BRICO_BOT_SNAPSHOT_INTERVAL_MS", 1000), reconcileIntervalMs: int("BRICO_BOT_RECONCILE_INTERVAL_MS", 300_000), reconnectDelayMs: int("BRICO_BOT_RECONNECT_DELAY_MS", 5000), diff --git a/backend/brico-bot/src/discord/commands/admin.ts b/backend/brico-bot/src/discord/commands/admin.ts index 20cdf542..ef032cd0 100644 --- a/backend/brico-bot/src/discord/commands/admin.ts +++ b/backend/brico-bot/src/discord/commands/admin.ts @@ -36,8 +36,8 @@ async function handleCommandMode(interaction: APIChatInputApplicationCommandInte return { content: mode === "grouped" - ? "Commands are now grouped under `/brico` (e.g. `/brico link`, `/brico watch display`)." - : "Commands are now top-level (e.g. `/link`, `/watch display`).", + ? "Commands are now grouped under `/brico` (e.g. `/brico link`, `/brico crafts display`)." + : "Commands are now top-level (e.g. `/link`, `/crafts display`).", }; } diff --git a/backend/brico-bot/src/discord/commands/funding.ts b/backend/brico-bot/src/discord/commands/funding.ts new file mode 100644 index 00000000..7c31199c --- /dev/null +++ b/backend/brico-bot/src/discord/commands/funding.ts @@ -0,0 +1,126 @@ +/** + * funding.ts — `/funding notify-setup`: sets the mention and wording of Stelo funding alerts for the + * Discord channel it is run in. The channel must already be linked with `/notify link`. + * + * Per-event on/off is derived server-side by `attachDiscordFundingTarget` from whether each template + * field was left empty. + */ +import type {DiscordFundingNotifyTarget, DiscordNotifySink} from "@brico/bindings/brico-app/types"; +import {MAX_NOTIFY_TEMPLATE_LENGTH} from "@brico/crafts/errors"; +import {DEFAULT_FUNDING_NOTIFY_TEMPLATE, FUNDING_NOTIFY_EVENTS, type FundingNotifyEvent} from "@brico/crafts/stelo"; +import { + type APIChatInputApplicationCommandInteraction, + type APILabelComponent, + type APIModalSubmission, + type APIModalSubmitInteraction, + ComponentType, + TextInputStyle, +} from "discord-api-types/v10"; +import {randomUUID} from "node:crypto"; + +import {timeReducerCall} from "../../metrics.ts"; +import {discordUserIdFor, isCommandReply, requireInteractionContext, requireLinkedAccount, requirePermission} from "./context.ts"; +import {type ModalMentionable, modalMentionableSelectValue, modalTextInputValue} from "./modal-options.ts"; +import {findOwnDiscordNotifySink, MENTIONABLE_FIELD_ID, mentionableField} from "./notify.ts"; +import type {CommandDeps, CommandReply, CommandShowModal, LeafCommand, ModalSubmitHandler} from "./registry.ts"; +import {labelField} from "./watch.ts"; + +const FUNDING_SETUP_MODAL_ID = "watch_funding_setup"; + +const FIELD_LABELS: Record = { + fundingFailed: "When funding a craft's bounty fails", + payoutShortfall: "When a payout can't be pulled from your wallet", + unclaimed: "When unclaimed money is moved", +}; + +function storedTemplate(target: DiscordFundingNotifyTarget, event: FundingNotifyEvent): string | undefined { + return event === "fundingFailed" ? target.fundingFailedTemplate + : event === "payoutShortfall" ? target.payoutShortfallTemplate : target.unclaimedTemplate; +} + +function templateField(event: FundingNotifyEvent, value: string): APILabelComponent { + return labelField(FIELD_LABELS[event], { + type: ComponentType.TextInput, + custom_id: event, + style: TextInputStyle.Short, + required: false, + max_length: MAX_NOTIFY_TEMPLATE_LENGTH, + value, + }); +} + +/** Prefills default wording for a new sink; an existing target shows its stored state, with an empty field meaning that event is disabled. */ +function buildFundingSetupModal(sink: DiscordNotifySink, existing: DiscordFundingNotifyTarget | undefined): CommandShowModal { + return { + modal: { + custom_id: FUNDING_SETUP_MODAL_ID, + title: "Setup Stelo funding alerts", + components: [ + mentionableField(existing?.mentionType ?? sink.defaultMentionType, existing?.mentionId ?? sink.defaultMentionId), + ...FUNDING_NOTIFY_EVENTS.map(event => templateField( + event, + existing ? (storedTemplate(existing, event) ?? "") : DEFAULT_FUNDING_NOTIFY_TEMPLATE[event], + )), + ], + }, + }; +} + +const handleFundingSetup = async (interaction: APIChatInputApplicationCommandInteraction, deps: CommandDeps): Promise => { + const ctx = requireInteractionContext(interaction, deps); + if (isCommandReply(ctx)) return ctx; + const permissionError = requirePermission(ctx); + if (permissionError) return permissionError; + const accountIdentity = requireLinkedAccount(ctx.conn, discordUserIdFor(ctx)); + if (isCommandReply(accountIdentity)) return accountIdentity; + + const sink = findOwnDiscordNotifySink(ctx.conn, accountIdentity, ctx.channelId); + if (!sink) return {content: "This channel isn't linked yet — run `/notify link` first."}; + + const existing = ctx.conn.db.allDiscordFundingNotifyTarget.iter() + .find(target => target.deletedAt === undefined && target.sinkId === sink.id && target.accountIdentity.isEqual(accountIdentity)); + return buildFundingSetupModal(sink, existing); +}; + +const handleFundingSetupSubmit: ModalSubmitHandler = async (interaction: APIModalSubmitInteraction, deps: CommandDeps): Promise => { + const ctx = requireInteractionContext(interaction, deps); + if (isCommandReply(ctx)) return ctx; + const permissionError = requirePermission(ctx); + if (permissionError) return permissionError; + const accountIdentity = requireLinkedAccount(ctx.conn, discordUserIdFor(ctx)); + if (isCommandReply(accountIdentity)) return accountIdentity; + + const sink = findOwnDiscordNotifySink(ctx.conn, accountIdentity, ctx.channelId); + if (!sink) return {content: "This channel isn't linked yet — run `/notify link` first."}; + + const submission: APIModalSubmission = interaction.data; + const mentionable: ModalMentionable | undefined = modalMentionableSelectValue(submission, MENTIONABLE_FIELD_ID); + const template = (event: FundingNotifyEvent) => modalTextInputValue(submission, event) ?? ""; + + try { + await timeReducerCall("attach_discord_funding_target", ctx.conn.reducers.attachDiscordFundingTarget({ + id: randomUUID(), + accountIdentity, + discordSinkId: sink.id, + fundingFailedTemplate: template("fundingFailed"), + payoutShortfallTemplate: template("payoutShortfall"), + unclaimedTemplate: template("unclaimed"), + mentionType: mentionable?.type, + mentionId: mentionable?.id, + mentionName: mentionable?.name, + })); + } catch (cause) { + const message = cause instanceof Error ? cause.message : String(cause); + deps.log.warn("attach_discord_funding_target failed", {error: message}); + return {content: `Couldn't set up funding alerts: ${message}`}; + } + return {content: "Stelo funding alerts are set up in this channel."}; +}; + +export const fundingNotifySetupCommand: LeafCommand = { + path: ["funding", "notify-setup"], + description: "Configure Discord alerts for Stelo funding problems in this channel.", + handler: handleFundingSetup, + modalCustomId: FUNDING_SETUP_MODAL_ID, + modalSubmit: handleFundingSetupSubmit, +}; diff --git a/backend/brico-bot/src/discord/commands/index.ts b/backend/brico-bot/src/discord/commands/index.ts index 52fa2c50..d59af349 100644 --- a/backend/brico-bot/src/discord/commands/index.ts +++ b/backend/brico-bot/src/discord/commands/index.ts @@ -4,21 +4,26 @@ */ import {commandModeCommand} from "./admin.ts"; import {clearCommand} from "./clear.ts"; +import {fundingNotifySetupCommand} from "./funding.ts"; import {linkCommand} from "./link.ts"; -import {watchNotifyLinkCommand, watchNotifySetupCommand, watchNotifyUnlinkCommand} from "./notify.ts"; +import {craftsNotifySetupCommand, notifyLinkCommand, notifyUnlinkCommand} from "./notify.ts"; +import {payRecordCommand, paySteloCommand} from "./pay.ts"; import type {LeafCommand} from "./registry.ts"; -import {watchDisplayLimitCommand, watchDisplayStickyCommand} from "./watch-display-options.ts"; -import {watchDisplayCommand, watchDisplayRemoveCommand} from "./watch.ts"; +import {craftsDisplayLimitCommand, craftsDisplayStickyCommand} from "./watch-display-options.ts"; +import {craftsDisplayCommand, craftsDisplayRemoveCommand} from "./watch.ts"; export const commands: LeafCommand[] = [ linkCommand, commandModeCommand, clearCommand, - watchDisplayCommand, - watchDisplayLimitCommand, - watchDisplayStickyCommand, - watchDisplayRemoveCommand, - watchNotifyLinkCommand, - watchNotifyUnlinkCommand, - watchNotifySetupCommand, + notifyLinkCommand, + notifyUnlinkCommand, + craftsDisplayCommand, + craftsDisplayLimitCommand, + craftsDisplayStickyCommand, + craftsDisplayRemoveCommand, + craftsNotifySetupCommand, + fundingNotifySetupCommand, + payRecordCommand, + paySteloCommand, ]; diff --git a/backend/brico-bot/src/discord/commands/notify.ts b/backend/brico-bot/src/discord/commands/notify.ts index 0f2e13be..22de47aa 100644 --- a/backend/brico-bot/src/discord/commands/notify.ts +++ b/backend/brico-bot/src/discord/commands/notify.ts @@ -1,8 +1,6 @@ /** - * notify.ts — `/watch notify-link`, `/watch notify-unlink` and `/watch notify-setup`, which manage - * Discord notification sinks (one per channel) and per-filter targets. They are hyphenated - * siblings rather than a `/watch notify` group because `LeafCommand.path` allows at most two - * segments. + * notify.ts — `/notify link`, `/notify unlink` and `/crafts notify-setup`, which manage + * Discord notification sinks (one per channel) and per-filter targets. * * `notify-setup`'s modal has no room for a filter picker (4-component cap, see `watch.ts`), so the * filter id travels in the modal `custom_id` as `` `${WATCH_NOTIFY_SETUP_MODAL_ID}:${filterId}` `` @@ -38,11 +36,11 @@ import {findOwnSavedFilter, labelField, watchDisplayFilterAutocomplete} from "./ const FILTER_OPTION_NAME = "filter"; const MENTIONABLE_OPTION_NAME = "mentionable"; const WATCH_NOTIFY_SETUP_MODAL_ID = "watch_notify_setup"; -const MENTIONABLE_FIELD_ID = "mentionable"; +export const MENTIONABLE_FIELD_ID = "mentionable"; const TEMPLATE_FIELD_IDS: Record = {added: "added", finished: "finished", removed: "removed"}; -/** The caller's own, non-tombstoned sink for this channel; `null` if `/watch notify-link` hasn't been run here. */ -function findOwnDiscordNotifySink(conn: DbConnection, accountIdentity: Identity, channelId: string): DiscordNotifySink | null { +/** The caller's own, non-tombstoned sink for this channel; `null` if `/notify link` hasn't been run here. */ +export function findOwnDiscordNotifySink(conn: DbConnection, accountIdentity: Identity, channelId: string): DiscordNotifySink | null { return conn.db.allDiscordNotifySink.iter() .find(sink => sink.deletedAt === undefined && sink.accountIdentity.isEqual(accountIdentity) && sink.channelId === channelId) ?? null; } @@ -91,13 +89,13 @@ const handleNotifyLink: CommandHandler = async (interaction: APIChatInputApplica return { content: existing - ? "This channel's notification settings are updated. Run `/watch notify-setup` to configure it for a saved filter." - : "This channel is now linked for notifications. Run `/watch notify-setup` to configure it for a saved filter.", + ? "This channel's notification settings are updated. Run `/crafts notify-setup` to configure it for a saved filter." + : "This channel is now linked for notifications. Run `/crafts notify-setup` to configure it for a saved filter.", }; }; -export const watchNotifyLinkCommand: LeafCommand = { - path: ["watch", "notify-link"], +export const notifyLinkCommand: LeafCommand = { + path: ["notify", "link"], description: "Link this channel as a Discord notification destination for your saved filters.", options: [ { @@ -133,13 +131,13 @@ const handleNotifyUnlink: CommandHandler = async (interaction: APIChatInputAppli return {content: "This channel is no longer linked for notifications."}; }; -export const watchNotifyUnlinkCommand: LeafCommand = { - path: ["watch", "notify-unlink"], +export const notifyUnlinkCommand: LeafCommand = { + path: ["notify", "unlink"], description: "Unlink this channel as a Discord notification destination.", handler: handleNotifyUnlink, }; -function mentionableField(mentionType: string | undefined, mentionId: string | undefined): APILabelComponent { +export function mentionableField(mentionType: string | undefined, mentionId: string | undefined): APILabelComponent { return labelField( "Mentionable", { @@ -202,7 +200,7 @@ const handleNotifySetup: CommandHandler = async (interaction: APIChatInputApplic const {conn, channelId} = ctx; const sink = findOwnDiscordNotifySink(conn, accountIdentity, channelId); - if (!sink) return {content: "This channel isn't linked yet — run `/watch notify-link` first."}; + if (!sink) return {content: "This channel isn't linked yet — run `/notify link` first."}; const filterId = stringOption(leafOptions(interaction), FILTER_OPTION_NAME); const filter = filterId ? findOwnSavedFilter(conn, accountIdentity, filterId) : undefined; @@ -222,11 +220,11 @@ const handleNotifySetupSubmit: ModalSubmitHandler = async (interaction: APIModal const {conn, channelId} = ctx; const sink = findOwnDiscordNotifySink(conn, accountIdentity, channelId); - if (!sink) return {content: "This channel isn't linked yet — run `/watch notify-link` first."}; + if (!sink) return {content: "This channel isn't linked yet — run `/notify link` first."}; const filterId = interaction.data.custom_id.slice(`${WATCH_NOTIFY_SETUP_MODAL_ID}:`.length); const filter = findOwnSavedFilter(conn, accountIdentity, filterId); - if (!filter) return {content: "That filter is no longer available — run `/watch notify-setup` again."}; + if (!filter) return {content: "That filter is no longer available — run `/crafts notify-setup` again."}; const submission: APIModalSubmission = interaction.data; const mentionable: ModalMentionable | undefined = modalMentionableSelectValue(submission, MENTIONABLE_FIELD_ID); @@ -256,8 +254,8 @@ const handleNotifySetupSubmit: ModalSubmitHandler = async (interaction: APIModal return {content: `Discord notifications for "${filter.name}" are set up in this channel.`}; }; -export const watchNotifySetupCommand: LeafCommand = { - path: ["watch", "notify-setup"], +export const craftsNotifySetupCommand: LeafCommand = { + path: ["crafts", "notify-setup"], description: "Configure Discord notifications for a saved filter in this channel.", options: [ { diff --git a/backend/brico-bot/src/discord/commands/pay.ts b/backend/brico-bot/src/discord/commands/pay.ts new file mode 100644 index 00000000..2c3abf1d --- /dev/null +++ b/backend/brico-bot/src/discord/commands/pay.ts @@ -0,0 +1,370 @@ +/** + * pay.ts — `/pay record` (log a manual payment to a player on the payer's ledger) and `/pay stelo` + * (send escrowed-currency funds from one of the payer's linked wallets to any wallet). Both resolve + * their arguments, ask for confirmation with Confirm/Cancel buttons, and act only on Confirm. + * The button `custom_id` carries the resolved arguments, which the click re-validates. + */ +import {CURRENCY_EMOJI, CURRENCY_LABELS, ESCROWED_BOUNTY_CURRENCIES, MANUALLY_RECORDED_BOUNTY_CURRENCIES} from "@brico/crafts/currencies"; +import { + type APIActionRowComponent, + type APIApplicationCommandAutocompleteInteraction, + type APIApplicationCommandOptionChoice, + type APIButtonComponent, + type APIChatInputApplicationCommandInteraction, + type APIMessageComponentInteraction, + ApplicationCommandOptionType, + ButtonStyle, + ComponentType, +} from "discord-api-types/v10"; + +import type {Logger} from "../../log.ts"; +import {timeReducerCall} from "../../metrics.ts"; +import {walletLabel} from "../../stelo/direct.ts"; +import {discordUserIdFor, isCommandReply, requireInteractionContext, requireLinkedAccount, resolveAccountIdentity} from "./context.ts"; +import {focusedOptionName, integerOption, leafOptions, stringOption} from "./options.ts"; +import type {AutocompleteHandler, CommandDeps, CommandHandler, CommandReply, ComponentHandler, LeafCommand} from "./registry.ts"; + +const RECORD_PREFIX = "pay_record"; +const STELO_PREFIX = "pay_stelo"; +const CONFIRM = "ok"; +const CANCEL = "cancel"; + +/** Discord's cap on `choices` for one option. */ +const MAX_CHOICES = 25; +const MAX_AMOUNT = Number.MAX_SAFE_INTEGER; + +function currencyChoices(currencies: readonly string[]): APIApplicationCommandOptionChoice[] { + return currencies.slice(0, MAX_CHOICES).map(id => ({name: CURRENCY_LABELS[id] ?? id, value: id})); +} + +/** Warns when a currency list no longer fits in a fixed `choices` list; the overflow is not offered until the option moves to autocomplete. */ +export function warnOnCurrencyChoiceOverflow(log: Logger): void { + for (const [command, currencies] of [["pay record", MANUALLY_RECORDED_BOUNTY_CURRENCIES], ["pay stelo", ESCROWED_BOUNTY_CURRENCIES]] as const) { + if (currencies.length > MAX_CHOICES) { + log.warn("currency list exceeds Discord's choice limit; extra currencies are not offered", {command, currencies: currencies.length, limit: MAX_CHOICES}); + } + } +} + +const currencyLabel = (currency: string) => CURRENCY_LABELS[currency] ?? currency; +const currencyEmojiLabel = (currency: string) => CURRENCY_EMOJI[currency] ? CURRENCY_EMOJI[currency] + currencyLabel(currency) : currencyLabel(currency); +const formatAmount = (amount: bigint) => amount.toLocaleString("en-US"); + +function confirmRow(prefix: string, payload: string): APIActionRowComponent[] { + return [{ + type: ComponentType.ActionRow, + components: [ + {type: ComponentType.Button, style: ButtonStyle.Success, label: "Confirm", custom_id: `${prefix}:${CONFIRM}:${payload}`}, + {type: ComponentType.Button, style: ButtonStyle.Secondary, label: "Cancel", custom_id: `${prefix}:${CANCEL}`}, + ], + }]; +} + +const parseId = (value: string): bigint | null => /^\d{1,20}$/.test(value) ? BigInt(value) : null; +/** The amount option as whole units; `null` if absent or zero (or negative, unless `allowNegative`). */ +function amountOption(interaction: APIChatInputApplicationCommandInteraction, allowNegative = false): bigint | null { + const value = integerOption(leafOptions(interaction), "amount"); + return value !== undefined && Number.isSafeInteger(value) && value !== 0 && (allowNegative || value > 0) ? BigInt(value) : null; +} + +// ── /pay record ────────────────────────────────────────────── + +interface PlayerRef { + id: bigint; + name: string; +} + +/** A player id (digits) or exact name (case-insensitive) typed into the `player` option. */ +function resolvePlayer(input: string, players: ReadonlyMap): PlayerRef | string { + const typed = input.trim(); + const id = parseId(typed); + if (id !== null) { + const player = players.get(id); + return player ? {id, name: player.name} : `No player with id ${typed} was found.`; + } + const wanted = typed.toLowerCase(); + for (const [playerId, player] of players) { + if (player.name.toLowerCase() === wanted) return {id: playerId, name: player.name}; + } + return `No player named "${typed}" was found — pick one from the suggestions or enter a player id.`; +} + +/** Players the account has an entitlement total with (optionally in one currency), as ids. */ +function payeeIds(deps: CommandDeps, payer: {toHexString(): string}, currency: string | undefined): Set { + const {entitlementTotals, totalKeysByPayer} = deps.appCache.state; + const ids = new Set(); + for (const key of totalKeysByPayer.get(payer.toHexString()) ?? []) { + const total = entitlementTotals.get(key); + if (total && (currency === undefined || total.currency === currency)) ids.add(total.payeePlayerId); + } + return ids; +} + +const playerChoice = (id: bigint, name: string) => ({name: `${name} (${id})`.slice(0, 100), value: id.toString()}); + +const recordPlayerAutocomplete: AutocompleteHandler = async (interaction: APIApplicationCommandAutocompleteInteraction, deps: CommandDeps) => { + const ctx = requireInteractionContext(interaction, deps); + if (isCommandReply(ctx)) return [{name: "brico is temporarily unavailable — try again in a moment.", value: "unavailable"}]; + const account = resolveAccountIdentity(ctx.conn, discordUserIdFor(ctx)); + if (account === null) return [{name: "Link your brico account first — run /link", value: "unlinked"}]; + + const options = leafOptions(interaction); + if (focusedOptionName(options) !== "player") return []; + const typed = (stringOption(options, "player") ?? "").trim().toLowerCase(); + const players = deps.players(); + + const choices: {name: string; value: string}[] = []; + const seen = new Set(); + const add = (id: bigint, name: string) => { + if (choices.length >= MAX_CHOICES || seen.has(id)) return; + seen.add(id); + choices.push(playerChoice(id, name)); + }; + + // Players this payer already has a ledger with come first. + for (const id of payeeIds(deps, account, stringOption(options, "currency"))) { + const name = players.get(id)?.name; + if (name !== undefined && (typed === "" || name.toLowerCase().includes(typed) || id.toString().startsWith(typed))) add(id, name); + } + if (typed === "" || choices.length >= MAX_CHOICES) return choices; + + const exactId = parseId(typed); + const exact = exactId === null ? undefined : players.get(exactId); + if (exactId !== null && exact) add(exactId, exact.name); + + // Name matches: prefix matches rank above substring matches. + const substring: [bigint, string][] = []; + for (const [id, player] of players) { + const name = player.name.toLowerCase(); + if (name.startsWith(typed)) add(id, player.name); + else if (substring.length < MAX_CHOICES && name.includes(typed)) substring.push([id, player.name]); + if (choices.length >= MAX_CHOICES) return choices; + } + for (const [id, name] of substring) add(id, name); + return choices; +}; + +const handleRecord: CommandHandler = async (interaction, deps): Promise => { + const ctx = requireInteractionContext(interaction, deps); + if (isCommandReply(ctx)) return ctx; + const account = requireLinkedAccount(ctx.conn, discordUserIdFor(ctx)); + if (isCommandReply(account)) return account; + + const options = leafOptions(interaction); + const currency = stringOption(options, "currency"); + if (currency === undefined || !MANUALLY_RECORDED_BOUNTY_CURRENCIES.includes(currency)) return {content: "That currency can't be recorded manually."}; + const amount = amountOption(interaction, true); + if (amount === null) return {content: "The amount must be a non-zero whole number."}; + const player = resolvePlayer(stringOption(options, "player") ?? "", deps.players()); + if (typeof player === "string") return {content: player}; + + return { + content: `Record payment of ${formatAmount(amount)} ${currencyLabel(currency)} to ${player.name} (${player.id})?`, + components: confirmRow(RECORD_PREFIX, `${currency}:${player.id}:${amount}`), + }; +}; + +const handleRecordComponent: ComponentHandler = async (interaction: APIMessageComponentInteraction, deps: CommandDeps): Promise => { + const [, action, currency, playerId, amountText] = interaction.data.custom_id.split(":"); + if (action === CANCEL) return {content: "Cancelled."}; + + const ctx = requireInteractionContext(interaction, deps); + if (isCommandReply(ctx)) return ctx; + const account = requireLinkedAccount(ctx.conn, discordUserIdFor(ctx)); + if (isCommandReply(account)) return account; + + const id = parseId(playerId ?? ""); + const amount = /^-?\d{1,16}$/.test(amountText ?? "") ? BigInt(amountText as string) : null; + const player = id === null ? undefined : deps.players().get(id); + if (action !== CONFIRM || currency === undefined || !MANUALLY_RECORDED_BOUNTY_CURRENCIES.includes(currency) || id === null || amount === null || amount === 0n || !player) { + return {content: "That confirmation is no longer valid — run `/pay record` again."}; + } + + try { + await timeReducerCall("record_bounty_payment_for", ctx.conn.reducers.recordBountyPaymentFor({ + payerAccountIdentity: account, + payeePlayerId: id, + currency, + delta: amount, + })); + } catch (cause) { + const message = cause instanceof Error ? cause.message : String(cause); + deps.log.warn("record_bounty_payment_for failed", {error: message}); + return {content: `Couldn't record the payment: ${message}`}; + } + return {content: `Recorded payment of ${formatAmount(amount)} ${currencyLabel(currency)} to ${player.name} (${id}).`}; +}; + +export const payRecordCommand: LeafCommand = { + path: ["pay", "record"], + description: "Record a manual payment you made to a player.", + options: [ + { + type: ApplicationCommandOptionType.String, + name: "currency", + description: "The currency you paid in.", + required: true, + choices: currencyChoices(MANUALLY_RECORDED_BOUNTY_CURRENCIES), + }, + { + type: ApplicationCommandOptionType.String, + name: "player", + description: "Who you paid: a player name or id.", + required: true, + autocomplete: true, + }, + { + type: ApplicationCommandOptionType.Integer, + name: "amount", + description: "How much you paid; negative records money received.", + required: true, + min_value: -MAX_AMOUNT, + max_value: MAX_AMOUNT, + }, + ], + handler: handleRecord, + autocomplete: recordPlayerAutocomplete, + componentPrefix: RECORD_PREFIX, + componentHandler: handleRecordComponent, +}; + +// ── /pay stelo ─────────────────────────────────────────────── + +/** Prefix of an autocomplete `to` value that already names a wallet by its internal id. */ +const PICKED_PREFIX = "id:"; + +const steloWalletAutocomplete: AutocompleteHandler = async (interaction: APIApplicationCommandAutocompleteInteraction, deps: CommandDeps) => { + const ctx = requireInteractionContext(interaction, deps); + if (isCommandReply(ctx)) return [{name: "brico is temporarily unavailable — try again in a moment.", value: "unavailable"}]; + const account = resolveAccountIdentity(ctx.conn, discordUserIdFor(ctx)); + if (account === null) return [{name: "Link your brico account first — run /link", value: "unlinked"}]; + + const options = leafOptions(interaction); + const focused = focusedOptionName(options); + if (focused !== "from" && focused !== "to") return []; + const currency = stringOption(options, "currency"); + if (currency === undefined) return [{name: "Pick a currency first", value: "no-currency"}]; + + if (focused === "to") { + try { + const hits = await deps.directTransfers?.searchWallets(currency, stringOption(options, "to") ?? "") ?? []; + return hits.slice(0, MAX_CHOICES).map(hit => ({name: walletLabel(hit).slice(0, 100), value: `${PICKED_PREFIX}${hit.accountId}`})); + } catch (cause) { + deps.log.warn("stelo wallet search failed", {error: cause instanceof Error ? cause.message : String(cause)}); + return []; + } + } + + const typed = (stringOption(options, "from") ?? "").trim().toLowerCase(); + return (deps.directTransfers?.sendableWallets(account, currency) ?? []) + .filter(wallet => (wallet.address.startsWith('#') ? wallet.address.slice(1) : wallet.address).toLowerCase().includes(typed)) + .slice(0, MAX_CHOICES) + .map(wallet => ({name: `#${wallet.address}`.slice(0, 100), value: wallet.accountId.toString()})); +}; + +const handleSteloPay: CommandHandler = async (interaction, deps): Promise => { + const ctx = requireInteractionContext(interaction, deps); + if (isCommandReply(ctx)) return ctx; + const account = requireLinkedAccount(ctx.conn, discordUserIdFor(ctx)); + if (isCommandReply(account)) return account; + if (!deps.directTransfers) return {content: "Stelo transfers aren't available right now."}; + + const options = leafOptions(interaction); + const currency = stringOption(options, "currency"); + if (currency === undefined || !ESCROWED_BOUNTY_CURRENCIES.includes(currency)) return {content: "That currency can't be sent through Stelo."}; + const amount = amountOption(interaction); + if (amount === null) return {content: "The amount must be a positive whole number."}; + const from = parseId((stringOption(options, "from") ?? "").trim()); + if (from === null) return {content: "Pick the wallet to send from out of the suggestions."}; + const toText = (stringOption(options, "to") ?? "").trim(); + let to: bigint; + if (toText.startsWith(PICKED_PREFIX) && parseId(toText.slice(PICKED_PREFIX.length)) !== null) { + to = BigInt(toText.slice(PICKED_PREFIX.length)); + } else { + const resolved = await deps.directTransfers.resolveWallet(currency, toText); + if (!resolved.ok) return {content: resolved.reason}; + to = resolved.accountId; + } + + const prepared = await deps.directTransfers.prepare({payer: account, currency, fromAccountId: from, toAccountId: to, amount}); + if (!prepared.ok) return {content: prepared.reason}; + const {plan} = prepared; + return { + content: `Transfer ${formatAmount(amount)} ${currencyEmojiLabel(currency)} from ${walletLabel(plan.from)} to ${walletLabel(plan.to)}?`, + components: confirmRow(STELO_PREFIX, `${currency}:${from}:${to}:${amount}`), + }; +}; + +const handleSteloComponent: ComponentHandler = async (interaction: APIMessageComponentInteraction, deps: CommandDeps): Promise => { + const [, action, currency, fromText, toText, amountText] = interaction.data.custom_id.split(":"); + if (action === CANCEL) return {content: "Cancelled."}; + + const ctx = requireInteractionContext(interaction, deps); + if (isCommandReply(ctx)) return ctx; + const account = requireLinkedAccount(ctx.conn, discordUserIdFor(ctx)); + if (isCommandReply(account)) return account; + if (!deps.directTransfers) return {content: "Stelo transfers aren't available right now."}; + + const from = parseId(fromText ?? ""); + const to = parseId(toText ?? ""); + const amount = parseId(amountText ?? ""); + if (action !== CONFIRM || currency === undefined || from === null || to === null || amount === null) { + return {content: "That confirmation is no longer valid — run `/pay stelo` again."}; + } + + // The books may have moved since the prompt, so the transfer is checked again before it is recorded. + const prepared = await deps.directTransfers.prepare({payer: account, currency, fromAccountId: from, toAccountId: to, amount}); + if (!prepared.ok) return {content: prepared.reason}; + const {plan} = prepared; + + try { + await deps.directTransfers.submit(plan); + } catch (cause) { + const message = cause instanceof Error ? cause.message : String(cause); + deps.log.warn("direct stelo transfer intent failed", {error: message}); + return {content: `Couldn't start the transfer: ${message}`}; + } + return { + content: `Sending ${formatAmount(amount)} ${currencyLabel(currency)} from ${walletLabel(plan.from)} to ${walletLabel(plan.to)}. It should arrive within a minute.`, + }; +}; + +export const paySteloCommand: LeafCommand = { + path: ["pay", "stelo"], + description: "Send Stelo currency from one of your linked wallets to any wallet.", + options: [ + { + type: ApplicationCommandOptionType.String, + name: "currency", + description: "The currency to send.", + required: true, + choices: currencyChoices(ESCROWED_BOUNTY_CURRENCIES), + }, + { + type: ApplicationCommandOptionType.String, + name: "from", + description: "Your linked wallet to send from.", + required: true, + autocomplete: true, + }, + { + type: ApplicationCommandOptionType.String, + name: "to", + description: "Destination: @user, #address, or any search text.", + required: true, + autocomplete: true, + }, + { + type: ApplicationCommandOptionType.Integer, + name: "amount", + description: "How much to send.", + required: true, + min_value: 1, + max_value: MAX_AMOUNT, + }, + ], + handler: handleSteloPay, + autocomplete: steloWalletAutocomplete, + componentPrefix: STELO_PREFIX, + componentHandler: handleSteloComponent, +}; diff --git a/backend/brico-bot/src/discord/commands/registry.test.ts b/backend/brico-bot/src/discord/commands/registry.test.ts index dca784bd..fc7f3bf9 100644 --- a/backend/brico-bot/src/discord/commands/registry.test.ts +++ b/backend/brico-bot/src/discord/commands/registry.test.ts @@ -8,7 +8,7 @@ import {test} from "node:test"; import {commands} from "./index.ts"; import type {LeafCommand} from "./registry.ts"; -import {buildCommandTree, leavesForScope, resolveCommand, resolveModalLeaf} from "./registry.ts"; +import {buildCommandTree, leavesForScope, resolveCommand, resolveComponentLeaf, resolveModalLeaf} from "./registry.ts"; const noop: LeafCommand["handler"] = async () => ({content: "ok"}); @@ -123,3 +123,15 @@ test("the real registered command set builds cleanly in both modes for each scop assert.doesNotThrow(() => buildCommandTree(scoped, "grouped")); } }); + +test("resolveComponentLeaf matches a `componentPrefix:` custom_id and nothing sharing only the prefix text", () => { + const leaves: LeafCommand[] = [{path: ["pay", "record"], description: "x", handler: noop, componentPrefix: "pay_record"}]; + assert.equal(resolveComponentLeaf(leaves, "pay_record:ok:hex-coin:1:5"), leaves[0]); + assert.equal(resolveComponentLeaf(leaves, "pay_record_other:ok"), null); +}); + +test("the pay commands register under one `pay` command with record and stelo subcommands", () => { + const [pay] = buildCommandTree(leavesForScope(commands, "guild").filter(leaf => leaf.path[0] === "pay"), "flat"); + assert.equal(pay.name, "pay"); + assert.deepEqual(pay.options?.map(option => option.name), ["record", "stelo"]); +}); diff --git a/backend/brico-bot/src/discord/commands/registry.ts b/backend/brico-bot/src/discord/commands/registry.ts index 2ce0c984..519c9113 100644 --- a/backend/brico-bot/src/discord/commands/registry.ts +++ b/backend/brico-bot/src/discord/commands/registry.ts @@ -7,18 +7,23 @@ import type {DiscordCommandMode} from "@brico/crafts/discord-guild"; import type {REST} from "@discordjs/rest"; import type { + APIActionRowComponent, APIApplicationCommandAutocompleteInteraction, APIApplicationCommandOption, APIApplicationCommandSubcommandGroupOption, APIApplicationCommandSubcommandOption, + APIButtonComponent, APIChatInputApplicationCommandInteraction, + APIMessageComponentInteraction, APIModalInteractionResponseCallbackData, APIModalSubmitInteraction, RESTPostAPIChatInputApplicationCommandsJSONBody, } from "discord-api-types/v10"; import {ApplicationCommandOptionType, ApplicationCommandType} from "discord-api-types/v10"; +import type {AppCache} from "../../app/app-cache.ts"; import type {BricoAppConnection} from "../../app/connection.ts"; import type {Logger} from "../../log.ts"; +import type {DirectTransfers} from "../../stelo/direct.ts"; /** Everything a command handler needs beyond the interaction payload itself. */ export interface CommandDeps { @@ -29,12 +34,20 @@ export interface CommandDeps { /** The shared `@discordjs/rest` client. */ rest: REST; applicationId: string; + /** BitCraft players mirrored from prism, by entity id. */ + players(): ReadonlyMap; + /** Mirror of brico-app's bounty tables. */ + appCache: AppCache; + /** Absent when Stelo is not configured. */ + directTransfers?: DirectTransfers; } export interface CommandReply { content: string; /** Defaults to `true` — almost every reply here carries something personal (a link code, an account state). */ ephemeral?: boolean; + /** Button rows shown under the message; clicks are routed by `LeafCommand.componentPrefix`. */ + components?: APIActionRowComponent[]; } /** Shows a modal as the interaction response instead of a reply (the two are mutually exclusive). */ @@ -59,6 +72,12 @@ export type ModalSubmitHandler = ( deps: CommandDeps, ) => Promise; +/** Handles a click on a button of a message this leaf sent; the reply replaces that message and its buttons. */ +export type ComponentHandler = ( + interaction: APIMessageComponentInteraction, + deps: CommandDeps, +) => Promise; + export type CommandScope = "guild" | "dm"; /** The leaves that belong in `scope`'s registration — those with no `scope` of their own, or a matching one. */ @@ -80,6 +99,9 @@ export interface LeafCommand { /** The `custom_id` of the modal `handler` shows; submissions are routed by it (`resolveModalLeaf`), so it must be unique across leaves. */ modalCustomId?: string; modalSubmit?: ModalSubmitHandler; + /** Button `custom_id`s of this leaf's messages start with `` `${componentPrefix}:` ``; clicks are routed by it (`resolveComponentLeaf`), so it must be unique across leaves. */ + componentPrefix?: string; + componentHandler?: ComponentHandler; } /** Chat-input and autocomplete interactions share the same `data.name`/`data.options` shape. */ @@ -167,8 +189,8 @@ export type CommandTreeMode = DiscordCommandMode; /** * Serializes `leaves` into the body for a `PUT /applications/{id}/guilds/{guildId}/commands` call. - * `"flat"` gives each first path segment its own top-level command (`/link`, `/watch display`); - * `"grouped"` nests everything under `groupName` (`/brico link`, `/brico watch display`). + * `"flat"` gives each first path segment its own top-level command (`/link`, `/crafts display`); + * `"grouped"` nests everything under `groupName` (`/brico link`, `/brico crafts display`). */ export function buildCommandTree( leaves: LeafCommand[], @@ -247,6 +269,11 @@ export function resolveCommand( return null; } +/** Finds the leaf whose `componentPrefix` starts a button's `custom_id`. */ +export function resolveComponentLeaf(leaves: LeafCommand[], customId: string): LeafCommand | null { + return leaves.find(leaf => leaf.componentPrefix !== undefined && customId.startsWith(`${leaf.componentPrefix}:`)) ?? null; +} + /** * Finds the leaf for a `MODAL_SUBMIT` by `custom_id`. Matches `modalCustomId` exactly or as a * `` `${modalCustomId}:${context}` `` prefix, so a modal can carry context (e.g. a filter id) in its id. diff --git a/backend/brico-bot/src/discord/commands/watch-display-options.ts b/backend/brico-bot/src/discord/commands/watch-display-options.ts index 4bda698c..ca037507 100644 --- a/backend/brico-bot/src/discord/commands/watch-display-options.ts +++ b/backend/brico-bot/src/discord/commands/watch-display-options.ts @@ -1,7 +1,7 @@ /** - * watch-display-options.ts — `/watch display-limit` and `/watch display-sticky`: change `limit` / + * watch-display-options.ts — `/crafts display-limit` and `/crafts display-sticky`: change `limit` / * `stickyMinutes` on an existing display (the setup modal has no fields for them). Siblings of - * `/watch display` rather than children, since a name can't be both a bare subcommand and a group. + * `/crafts display` rather than children, since a name can't be both a bare subcommand and a group. * Only edits an existing `discord_watch_display` row; it never creates one. */ import {MAX_DISPLAY_ROWS_HARD_CAP, MAX_STICKY_MINUTES, MIN_STICKY_MINUTES} from "@brico/crafts/discord-display"; @@ -46,7 +46,7 @@ function makeWatchDisplayOptionCommand(spec: WatchDisplayOptionSpec): LeafComman if (!filter) return {content: "That filter is no longer available — pick it again."}; const existing = findExistingDisplay(conn, accountIdentity, filterId, channelId); - if (!existing) return {content: `There's no watch display for "${filter.name}" in this channel yet — run \`/watch display\` first.`}; + if (!existing) return {content: `There's no watch display for "${filter.name}" in this channel yet — run \`/crafts display\` first.`}; try { await timeReducerCall("attach_discord_watch_display", conn.reducers.attachDiscordWatchDisplay({ @@ -97,8 +97,8 @@ function makeWatchDisplayOptionCommand(spec: WatchDisplayOptionSpec): LeafComman }; } -export const watchDisplayLimitCommand: LeafCommand = makeWatchDisplayOptionCommand({ - path: ["watch", "display-limit"], +export const craftsDisplayLimitCommand: LeafCommand = makeWatchDisplayOptionCommand({ + path: ["crafts", "display-limit"], commandDescription: "Change how many crafts a watch display shows at once.", optionName: "cap", optionDescription: "Maximum number of crafts to show.", @@ -108,8 +108,8 @@ export const watchDisplayLimitCommand: LeafCommand = makeWatchDisplayOptionComma formatSuccess: (filterName, value) => `Watch display for "${filterName}" now shows up to ${value} craft${value === 1 ? "" : "s"}.`, }); -export const watchDisplayStickyCommand: LeafCommand = makeWatchDisplayOptionCommand({ - path: ["watch", "display-sticky"], +export const craftsDisplayStickyCommand: LeafCommand = makeWatchDisplayOptionCommand({ + path: ["crafts", "display-sticky"], commandDescription: "Change how often a watch display reposts itself to stay at the bottom of the channel.", optionName: "minutes", optionDescription: "Minutes between reposts (0 disables reposting).", diff --git a/backend/brico-bot/src/discord/commands/watch.ts b/backend/brico-bot/src/discord/commands/watch.ts index 694e6f02..0a7eb58d 100644 --- a/backend/brico-bot/src/discord/commands/watch.ts +++ b/backend/brico-bot/src/discord/commands/watch.ts @@ -1,5 +1,5 @@ /** - * watch.ts — `/watch display` and `/watch display-remove`. `/watch display` opens a setup modal + * watch.ts — `/crafts display` and `/crafts display-remove`. `/crafts display` opens a setup modal * (filter, style, field presets, sort) whose submit writes `attachDiscordWatchDisplay`; the * optional `filter` argument only chooses which existing display prefills it. Posting and editing * the message is `discord/display-manager.ts`'s job. @@ -119,7 +119,7 @@ function channelDisplays(conn: DbConnection, channelId: string) { return conn.db.allDiscordWatchDisplay.iter().filter(display => display.deletedAt === undefined && display.channelId === channelId); } -/** Autocomplete for `/watch display-remove`: the channel's existing displays, labeled by their filter's name; the value is the display id. */ +/** Autocomplete for `/crafts display-remove`: the channel's existing displays, labeled by their filter's name; the value is the display id. */ const watchDisplayRemoveAutocomplete: AutocompleteHandler = async (interaction: APIApplicationCommandAutocompleteInteraction, deps: CommandDeps) => { const ctx = requireInteractionContext(interaction, deps); if (isCommandReply(ctx)) return [{name: "brico is temporarily unavailable — try again in a moment.", value: "unavailable"}]; @@ -238,7 +238,7 @@ async function handleWatchDisplaySubmit(interaction: APIModalSubmitInteraction, const filterId = modalStringSelectValue(submission, FILTER_FIELD_ID); const filter = filterId ? findOwnSavedFilter(conn, accountIdentity, filterId) : undefined; - if (!filter) return {content: "That filter is no longer available — run `/watch display` again."}; + if (!filter) return {content: "That filter is no longer available — run `/crafts display` again."}; const style = modalStringSelectValue(submission, STYLE_FIELD_ID); if (!style || !isDiscordDisplayStyle(style)) return {content: "Choose a style."}; @@ -297,8 +297,8 @@ async function handleWatchDisplaySubmit(interaction: APIModalSubmitInteraction, return {content: `Watch display for "${filter.name}" is set up in this channel.`}; } -export const watchDisplayCommand: LeafCommand = { - path: ["watch", "display"], +export const craftsDisplayCommand: LeafCommand = { + path: ["crafts", "display"], description: "Set up a live-updating message of a saved filter's current matches in this channel.", options: [ { @@ -315,7 +315,7 @@ export const watchDisplayCommand: LeafCommand = { modalSubmit: handleWatchDisplaySubmit, }; -/** `/watch display-remove` — tombstones the `(account, filter, channel)` display. `display-manager.ts` also does this when the channel returns 403/404. */ +/** `/crafts display-remove` — tombstones the `(account, filter, channel)` display. `display-manager.ts` also does this when the channel returns 403/404. */ const handleWatchDisplayRemove: CommandHandler = async (interaction: APIChatInputApplicationCommandInteraction, deps: CommandDeps): Promise => { const ctx = requireInteractionContext(interaction, deps); if (isCommandReply(ctx)) return ctx; @@ -343,8 +343,8 @@ const handleWatchDisplayRemove: CommandHandler = async (interaction: APIChatInpu return {content: `Watch display for "${filterName}" disabled.`}; }; -export const watchDisplayRemoveCommand: LeafCommand = { - path: ["watch", "display-remove"], +export const craftsDisplayRemoveCommand: LeafCommand = { + path: ["crafts", "display-remove"], description: "Remove a watch display from this channel.", options: [ { diff --git a/backend/brico-bot/src/discord/display-manager.ts b/backend/brico-bot/src/discord/display-manager.ts index af8c1947..ee209d2f 100644 --- a/backend/brico-bot/src/discord/display-manager.ts +++ b/backend/brico-bot/src/discord/display-manager.ts @@ -124,7 +124,7 @@ export function createDisplayManager(options: DisplayManagerOptions): DisplayMan }); } - /** The channel stopped accepting posts (403/404): tombstone the display (same reducer as `/watch display-remove`). */ + /** The channel stopped accepting posts (403/404): tombstone the display (same reducer as `/crafts display-remove`). */ async function markDeleted(conn: DbConnection, display: DiscordWatchDisplay): Promise { if (!conn.isActive) return; displayUpdatesTotal.inc({outcome: "detached"}); diff --git a/backend/brico-bot/src/discord/interactions.ts b/backend/brico-bot/src/discord/interactions.ts index db3f8942..60d04164 100644 --- a/backend/brico-bot/src/discord/interactions.ts +++ b/backend/brico-bot/src/discord/interactions.ts @@ -4,6 +4,7 @@ import type { APIChatInputApplicationCommandInteraction, APIInteraction, APIInteractionResponse, + APIMessageComponentInteraction, APIModalSubmitInteraction } from "discord-api-types/v10"; import {InteractionResponseType, InteractionType, MessageFlags} from "discord-api-types/v10"; @@ -12,8 +13,8 @@ import type {IncomingMessage, ServerResponse} from "node:http"; import type {Logger} from "../log.ts"; import {commandInvocationsTotal} from "../metrics.ts"; -import type {CommandDeps, LeafCommand} from "./commands/registry.ts"; -import {resolveCommand, resolveModalLeaf} from "./commands/registry.ts"; +import type {CommandDeps, CommandReply, LeafCommand} from "./commands/registry.ts"; +import {resolveCommand, resolveComponentLeaf, resolveModalLeaf} from "./commands/registry.ts"; export interface InteractionsHandlerOptions { /** Ed25519 public key from the Discord Developer Portal — every request is signed against this. */ @@ -40,10 +41,18 @@ function sendJson(res: ServerResponse, status: number, body: APIInteractionRespo res.end(json); } -function messageResponse(content: string, ephemeral: boolean): APIInteractionResponse { +function messageResponse(content: string, ephemeral: boolean, components?: CommandReply["components"]): APIInteractionResponse { return { type: InteractionResponseType.ChannelMessageWithSource, - data: {content, flags: ephemeral ? MessageFlags.Ephemeral : undefined}, + data: {content, flags: ephemeral ? MessageFlags.Ephemeral : undefined, components}, + }; +} + +/** Replaces the clicked message; `components: []` removes its buttons. */ +function updateResponse(reply: CommandReply): APIInteractionResponse { + return { + type: InteractionResponseType.UpdateMessage, + data: {content: reply.content, components: reply.components ?? []}, }; } @@ -124,9 +133,28 @@ export function createInteractionsHandler(options: InteractionsHandlerOptions) { return; } + if (interaction.type === InteractionType.MessageComponent) { + const componentInteraction = interaction as APIMessageComponentInteraction; + const command = resolveComponentLeaf(options.leaves, componentInteraction.data.custom_id); + if (!command?.componentHandler) { + options.log.warn("unrecognized component interaction", {customId: componentInteraction.data.custom_id}); + sendJson(res, 200, messageResponse("This button isn't recognized.", true)); + return; + } + try { + sendJson(res, 200, updateResponse(await command.componentHandler(componentInteraction, options.deps))); + } catch (cause) { + options.log.error("component handler threw", { + command: command.path.join("."), + error: cause instanceof Error ? cause.message : String(cause), + }); + sendJson(res, 200, updateResponse({content: "Something went wrong handling that button."})); + } + return; + } + if (interaction.type !== InteractionType.ApplicationCommand) { - // No component handlers are registered. - options.log.warn("unhandled interaction type", {type: interaction.type}); + options.log.warn("unhandled interaction type", {type: (interaction as {type: number}).type}); sendJson(res, 200, messageResponse("This interaction type isn't supported yet.", true)); return; } @@ -145,7 +173,7 @@ export function createInteractionsHandler(options: InteractionsHandlerOptions) { if ("modal" in result) { sendJson(res, 200, {type: InteractionResponseType.Modal, data: result.modal}); } else { - sendJson(res, 200, messageResponse(result.content, result.ephemeral ?? true)); + sendJson(res, 200, messageResponse(result.content, result.ephemeral ?? true, result.components)); } status = "ok"; } catch (cause) { diff --git a/backend/brico-bot/src/discord/sync-guild-commands.ts b/backend/brico-bot/src/discord/sync-guild-commands.ts index e9c28e8e..36fb7bba 100644 --- a/backend/brico-bot/src/discord/sync-guild-commands.ts +++ b/backend/brico-bot/src/discord/sync-guild-commands.ts @@ -10,6 +10,7 @@ import type {REST} from "@discordjs/rest"; import {Routes} from "discord-api-types/v10"; import type {Logger} from "../log.ts"; +import {warnOnCurrencyChoiceOverflow} from "./commands/pay.ts"; import {registerGlobalCommands, registerGuildCommands} from "./register-guild-commands.ts"; /** Max page size for `GET /users/@me/guilds`. */ @@ -31,6 +32,7 @@ async function listCurrentGuildIds(rest: REST): Promise { export async function syncAllGuildCommands(options: {rest: REST; applicationId: string; conn: DbConnection; log: Logger}): Promise { const {rest, applicationId, conn, log} = options; + warnOnCurrencyChoiceOverflow(log); const modeByGuildId = new Map(); for (const row of conn.db.allDiscordGuildInstall.iter()) modeByGuildId.set(row.guildId, row.commandMode); diff --git a/backend/brico-bot/src/main.ts b/backend/brico-bot/src/main.ts index 8708c534..3f083410 100644 --- a/backend/brico-bot/src/main.ts +++ b/backend/brico-bot/src/main.ts @@ -12,6 +12,7 @@ import {createAppCache} from "./app/app-cache.ts"; import {createBountyEngine} from "./app/bounty-sink.ts"; import {startBricoAppConnection} from "./app/connection.ts"; +import {createEscrowCache} from "./app/escrow-cache.ts"; import {createNotificationSink} from "./app/notification-sink.ts"; import {createAccountWatchSource} from "./app/watch-source.ts"; import {createDiscordOAuthClient} from "./auth/discord-oauth.ts"; @@ -36,6 +37,10 @@ import {timeReducerCall} from "./metrics.ts"; import {createPrismRelay, type PrismRelay} from "./relay/prism.ts"; import {createRowCache} from "./relay/row-cache.ts"; import {createFileTokenStore} from "./spacetime/token-store.ts"; +import {createSteloAuth, type SteloAuth} from "./stelo/auth.ts"; +import {startSteloConnection, type SteloConnection} from "./stelo/connection.ts"; +import {createSteloManager, type SteloManager} from "./stelo/manager.ts"; +import {createSteloCache} from "./stelo/stelo-cache.ts"; async function main(): Promise { const envFile = loadEnvFile(); @@ -47,6 +52,7 @@ async function main(): Promise { node: process.version, prism: `${config.prism.uri}/${config.prism.database}`, bricoApp: config.bricoApp ? `${config.bricoApp.uri}/${config.bricoApp.database}` : "(disabled)", + stelo: config.stelo ? `${config.stelo.target.uri}/${config.stelo.target.database}` : "(disabled)", discordBot: config.discordBot ? "(enabled)" : "(disabled)", snapshotIntervalMs: config.snapshotIntervalMs, stateDir: config.stateDir, @@ -82,6 +88,7 @@ async function main(): Promise { // without restructuring construction order. let relay: PrismRelay | undefined; const appCache = createAppCache(log.child("app")); + const escrowCache = createEscrowCache(); // The brico-app connection comes up first so the watch source can consult it from the very // first prism snapshot. Non-blocking — the socket dials in the background — and an unreachable @@ -90,6 +97,7 @@ async function main(): Promise { ...supervisor, target: config.bricoApp, cache: appCache, + escrow: escrowCache, reconcileIntervalMs: config.reconcileIntervalMs, onRowsChanged: () => { log.debug("brico-app rows changed"); @@ -109,6 +117,45 @@ async function main(): Promise { }); if (app.disabledReason) log.warn("brico-app half of the bridge is not running", {reason: app.disabledReason}); + // Local copy so closures below can narrow it (TS won't narrow `config.discordBot` across them). + const discordBot = config.discordBot; + + // Shared REST client for all outgoing Discord calls. + const discordRest = discordBot ? createDiscordRestClient(discordBot.token) : null; + + // The Stelo connection holds escrowed bounty money. It needs brico-app too, since every transfer + // is recorded there first; without either, escrowed currencies simply never get funded. + let stelo: SteloConnection | null = null; + let steloAuth: SteloAuth | null = null; + let steloManager: SteloManager | null = null; + if (config.stelo && config.bricoApp) { + const steloCache = createSteloCache(); + steloAuth = createSteloAuth({config: config.stelo, tokens, log}); + steloAuth.start(); + stelo = startSteloConnection({ + log, + delayMs: supervisor.delayMs, + maxDelayMs: supervisor.maxDelayMs, + auth: steloAuth, + config: config.stelo, + cache: steloCache, + onRowsChanged: () => relay?.mark(), + }); + steloManager = createSteloManager({ + config: config.stelo, + app, + stelo, + escrow: escrowCache, + steloCache, + playerAccounts: () => appCache.state.playerAccounts, + playerName: playerId => cache.snapshot.players.get(playerId)?.name, + discord: discordRest ? {rest: discordRest, frontendOrigin: new URL(config.linkReturnUrl).origin} : undefined, + log, + }); + } else if (config.stelo) { + log.warn("Stelo escrow is disabled because brico-app is disabled"); + } + // The BitAuth/Discord OAuth relying-party HTTP surface. Independent of the bridge/watch // matching below; it only ever calls `noteIntegrationLinkExternal` on the same `brico-app` // connection. @@ -125,12 +172,6 @@ async function main(): Promise { if (!bitauthClient) log.warn("BitAuth linking is disabled — set BITAUTH_CLIENT_ID/BITAUTH_CLIENT_SECRET to enable it"); if (!discordOAuthClient) log.warn("Discord OAuth linking is disabled — set DISCORD_OAUTH_CLIENT_ID/DISCORD_OAUTH_CLIENT_SECRET to enable it"); - // Local copy so closures below can narrow it (TS won't narrow `config.discordBot` across them). - const discordBot = config.discordBot; - - // Shared REST client for all outgoing Discord calls. - const discordRest = discordBot ? createDiscordRestClient(discordBot.token) : null; - // Slash commands. No gateway: interactions arrive as signed HTTP POSTs on the HTTP server below. const discordInteractions = discordBot && discordRest ? createInteractionsHandler({ @@ -142,6 +183,9 @@ async function main(): Promise { linkReturnUrl: config.linkReturnUrl, rest: discordRest, applicationId: discordBot.applicationId, + players: () => cache.snapshot.players, + appCache, + directTransfers: steloManager?.direct, }, log: log.child("discord"), }) @@ -175,7 +219,7 @@ async function main(): Promise { log.warn("Discord self-serve install is disabled — set DISCORD_BOT_CLIENT_SECRET to enable /discord/install (the manual invite flow still works without it)"); } - // Periodically refreshes `/watch display` messages from the bridge's per-snapshot rows (`onRowsComputed`). + // Periodically refreshes `/crafts display` messages from the bridge's per-snapshot rows (`onRowsComputed`). const displayManager = config.discordBot && discordRest ? createDisplayManager({ app, @@ -222,7 +266,7 @@ async function main(): Promise { const watches = createAccountWatchSource(app, log.child("watches")); - const bounty = createBountyEngine(app, appCache, log); + const bounty = createBountyEngine(app, appCache, log, steloManager ? {escrow: escrowCache, gate: steloManager.funding, status: steloManager.fundingStatus} : undefined); const cache = createRowCache(recipes); const bridge = createBridge({ @@ -236,6 +280,7 @@ async function main(): Promise { ), recipes, bounty, + stelo: steloManager ?? undefined, appCache, onRowsComputed: rows => displayManager?.onRowsComputed(rows), }); @@ -251,9 +296,13 @@ async function main(): Promise { }); relay.start(); + // Time-based Stelo work (payout sweeps, retry backoff, resending an unechoed transfer) has no row change to trigger it. + const steloTimer = steloManager ? setInterval(() => relay?.mark(), 10_000) : null; + steloTimer?.unref(); + const heartbeat = config.heartbeatMs > 0 ? setInterval(() => { - log.info(`heartbeat prism=${relay.connection.status} bricoApp=${app.connection?.status ?? "disabled"} ${bridge.describe()}`); + log.info(`heartbeat prism=${relay.connection.status} bricoApp=${app.connection?.status ?? "disabled"} stelo=${stelo?.connection?.status ?? "disabled"} ${bridge.describe()}`); }, config.heartbeatMs) : null; @@ -263,10 +312,13 @@ async function main(): Promise { shuttingDown = true; log.info("shutting down", {signal, summary: bridge.describe()}); if (heartbeat) clearInterval(heartbeat); + if (steloTimer) clearInterval(steloTimer); displayManager?.stop(); linkHttp.stop(); relay.stop(); app.stop(); + stelo?.stop(); + steloAuth?.stop(); // Give the SDK's close frames a moment, then exit rather than trusting every handle to be // released — a bridge worker that hangs on SIGTERM gets SIGKILLed by its supervisor anyway. setTimeout(() => process.exit(0), 250).unref(); diff --git a/backend/brico-bot/src/metrics.ts b/backend/brico-bot/src/metrics.ts index c1549631..2c456055 100644 --- a/backend/brico-bot/src/metrics.ts +++ b/backend/brico-bot/src/metrics.ts @@ -116,7 +116,7 @@ export const notificationsTotal = new Counter({ registers: [registry], }); -/** `/watch display` messages currently being kept in sync. */ +/** `/crafts display` messages currently being kept in sync. */ export const activeDisplays = new Gauge({ name: "brico_bot_active_displays", help: "Live Discord watch displays", @@ -171,3 +171,18 @@ export function timeReducerCall(reducer: string, call: Promise): Promise quiet}; +const CLIENT_ID = "client_test"; + +function jwt(claims: Record): string { + const part = (value: unknown) => Buffer.from(JSON.stringify(value)).toString("base64url"); + return `${part({alg: "none", typ: "JWT"})}.${part(claims)}.`; +} + +function memoryTokens(initial: Record = {}): TokenStore & {values: Map} { + const values = new Map(Object.entries(initial)); + return {values, read: key => values.get(key), write: (key, token) => void values.set(key, token)}; +} + +/** A minimal OIDC provider: discovery plus a refresh-grant token endpoint that rotates the refresh token. */ +async function fakeProvider(options: {fail?: () => boolean} = {}) { + let issued = 0; + const grants: string[] = []; + const server: Server = createServer((req, res) => { + const origin = `http://127.0.0.1:${(server.address() as AddressInfo).port}`; + if (req.url === "/.well-known/openid-configuration") { + res.setHeader("content-type", "application/json"); + res.end(JSON.stringify({issuer: origin, token_endpoint: `${origin}/token`, jwks_uri: `${origin}/jwks`})); + return; + } + let body = ""; + req.on("data", chunk => (body += chunk)); + req.on("end", () => { + const form = new URLSearchParams(body); + grants.push(`${form.get("grant_type")}:${form.get("refresh_token")}`); + res.setHeader("content-type", "application/json"); + if (options.fail?.()) { + res.statusCode = 400; + res.end(JSON.stringify({error: "invalid_grant"})); + return; + } + issued += 1; + const now = Math.floor(Date.now() / 1000); + res.end(JSON.stringify({ + token_type: "Bearer", + access_token: `access-${issued}`, + refresh_token: `refresh-${issued}`, + id_token: jwt({iss: origin, sub: "bot", aud: CLIENT_ID, iat: now, exp: now + 86_400}), + })); + }); + }); + await new Promise(resolve => server.listen(0, "127.0.0.1", resolve)); + return {server, grants, origin: `http://127.0.0.1:${(server.address() as AddressInfo).port}`}; +} + +function config(authority: string, webhook: string | null, refreshIntervalMs = 3_600_000): SteloConfig { + return { + target: {label: "stelo", uri: "http://x", database: "stelofinance"}, + auth: {mode: "refresh", authority, clientId: CLIENT_ID, refreshIntervalMs}, + authAlertWebhook: webhook, + currencies: {}, + refPrefix: "bx", + payoutIntervalMs: 1, + minPayout: 1, + retryBaseMs: 1, + retryMaxMs: 1, + }; +} + +const servers: Server[] = []; +afterEach(() => { + for (const server of servers.splice(0)) server.close(); +}); + +describe("isAuthRejection", () => { + it("separates a rejected token from an outage", () => { + assert.equal(isAuthRejection(new Error("Failed to verify token: Unauthorized")), true); + assert.equal(isAuthRejection(new Error("Failed to verify token: Bad Gateway")), false); + assert.equal(isAuthRejection(new Error("Received network error or non-101 status code.")), false); + assert.equal(isAuthRejection(new TypeError("fetch failed")), false); + }); +}); + +describe("refresh mode", () => { + it("renews on start when the id token is due, storing both new tokens", async () => { + const provider = await fakeProvider(); + servers.push(provider.server); + const old = Math.floor(Date.now() / 1000) - 7200; + const tokens = memoryTokens({ + [ID_TOKEN_KEY]: jwt({iss: provider.origin, sub: "bot", aud: CLIENT_ID, iat: old, exp: old + 86_400}), + [REFRESH_TOKEN_KEY]: "refresh-0", + }); + const auth = createSteloAuth({config: config(provider.origin, null), tokens, log: quiet}); + auth.start(); + for (let i = 0; i < 100 && tokens.values.get(REFRESH_TOKEN_KEY) === "refresh-0"; i++) await new Promise(r => setTimeout(r, 20)); + auth.stop(); + assert.deepEqual(provider.grants, ["refresh_token:refresh-0"]); + assert.equal(tokens.values.get(REFRESH_TOKEN_KEY), "refresh-1"); + assert.notEqual(tokens.values.get(ID_TOKEN_KEY), undefined); + assert.equal(auth.tokens.read("ignored"), tokens.values.get(ID_TOKEN_KEY), "the dial token is the renewed id token"); + }); + + it("alerts once when renewal fails, and says so when it recovers", async () => { + let failing = true; + const provider = await fakeProvider({fail: () => failing}); + servers.push(provider.server); + const old = Math.floor(Date.now() / 1000) - 7200; + const tokens = memoryTokens({ + [ID_TOKEN_KEY]: jwt({iss: provider.origin, sub: "bot", aud: CLIENT_ID, iat: old, exp: old + 86_400}), + [REFRESH_TOKEN_KEY]: "refresh-0", + }); + const messages: string[] = []; + const fetchStub = (async (_url: unknown, init: {body: string}) => { + messages.push(JSON.parse(init.body).content); + return new Response(null, {status: 204}); + }) as unknown as typeof fetch; + const auth = createSteloAuth({config: config(provider.origin, "http://hook"), tokens, log: quiet, fetch: fetchStub}); + auth.start(); + for (let i = 0; i < 100 && messages.length === 0; i++) await new Promise(r => setTimeout(r, 20)); + assert.equal(messages.length, 1); + assert.match(messages[0], /cannot authenticate to Stelo: token renewal failed/); + + auth.noteConnectError(new Error("Failed to verify token: Unauthorized")); + auth.noteConnectError(new Error("Failed to verify token: Unauthorized")); + assert.equal(messages.length, 1, "one alert per failure episode"); + + failing = false; + auth.noteConnected(); + auth.stop(); + await new Promise(r => setTimeout(r, 20)); + assert.equal(messages.length, 2); + assert.match(messages[1], /authenticated to Stelo again/); + }); + + it("creates the empty token files on start so a human can fill them in", () => { + const tokens = memoryTokens(); + const auth = createSteloAuth({config: config("http://127.0.0.1:1", null), tokens, log: quiet}); + auth.start(); + auth.stop(); + assert.deepEqual([...tokens.values.entries()], [[ID_TOKEN_KEY, ""], [REFRESH_TOKEN_KEY, ""]]); + }); + + it("refuses to dial anonymously without an id token", () => { + const auth = createSteloAuth({config: config("http://127.0.0.1:1", null), tokens: memoryTokens(), log: quiet}); + assert.throws(() => auth.tokens.read("x"), /missing/); + }); +}); + +describe("permanent mode", () => { + it("passes the stored token store through and alerts on a rejected token", async () => { + const messages: string[] = []; + const fetchStub = (async (_url: unknown, init: {body: string}) => { + messages.push(JSON.parse(init.body).content); + return new Response(null, {status: 204}); + }) as unknown as typeof fetch; + const tokens = memoryTokens(); + const auth = createSteloAuth({ + config: {...config("x", "http://hook"), auth: {mode: "permanent"}}, + tokens, + log: quiet, + fetch: fetchStub, + }); + assert.equal(auth.tokens, tokens); + auth.noteConnectError(new Error("Failed to verify token: Bad Gateway")); + assert.equal(messages.length, 0, "an outage is not an auth failure"); + auth.noteConnectError(new Error("Failed to verify token: Unauthorized")); + assert.equal(messages.length, 1); + }); +}); diff --git a/backend/brico-bot/src/stelo/auth.ts b/backend/brico-bot/src/stelo/auth.ts new file mode 100644 index 00000000..53a6200c --- /dev/null +++ b/backend/brico-bot/src/stelo/auth.ts @@ -0,0 +1,256 @@ +/** + * auth.ts — how the bot's Stelo connection gets its id token, and the alert when that stops working. + * + * `permanent` mode uses the token stored for the connection as-is. `refresh` mode keeps a short-lived + * id token and a one-time-use refresh token in the state dir and renews both through oidc-client-ts's + * `signinSilent`; a human does the interactive login and seeds the two files (see `.env.example`). + */ +import {decodeJwt} from "jose"; +import {type StateStore, UserManager} from "oidc-client-ts"; + +import type {SteloConfig} from "../config.ts"; +import type {Logger} from "../log.ts"; +import type {TokenStore} from "../spacetime/token-store.ts"; + +export interface SteloAuth { + /** Token source for the supervised connection; read on every dial. */ + readonly tokens: TokenStore; + /** Starts scheduled renewal (refresh mode); does nothing otherwise. */ + start(): void; + stop(): void; + /** Feed every connect failure here; Stelo rejecting the token (not an outage) alerts a human. */ + noteConnectError(cause: unknown): void; + /** Feed every successful connect here; ends the current alert episode. */ + noteConnected(): void; +} + +export interface SteloAuthOptions { + config: SteloConfig; + /** The bot's file token store; refresh mode keeps its two files in it. */ + tokens: TokenStore; + log: Logger; + fetch?: typeof fetch; +} + +/** Keys in the file token store (`stelo_id_token.token`, `stelo_refresh_token.token`). */ +export const ID_TOKEN_KEY = "stelo:id_token"; +export const REFRESH_TOKEN_KEY = "stelo:refresh_token"; + +const SCOPE = "openid offline_access"; +/** Renew this long before the id token expires, if that is sooner than the refresh interval. */ +const EXPIRY_MARGIN_MS = 60_000; +/** Floor between renewals, so a token that is already near expiry cannot spin. */ +const MIN_DELAY_MS = 1000; +/** Pause between attempts after a failed renewal; the refresh token outlives the 12 h interval, so retrying is worthwhile. */ +const RETRY_MS = 10 * 60 * 1000; + +/** + * True when Stelo/maincloud refused the token, as opposed to being unreachable. The SDK POSTs the token to + * `/v1/identity/websocket-token` before opening the socket and fails with that response's status text. + */ +export function isAuthRejection(cause: unknown): boolean { + return cause instanceof Error && /^Failed to verify token: (Unauthorized|Forbidden)$/i.test(cause.message); +} + +/** One alert per failure episode: the next is allowed after `recover()`. */ +function createAlerter(webhook: string | null, log: Logger, doFetch: typeof fetch) { + let alerted = false; + + async function post(content: string): Promise { + if (webhook === null) return; + try { + const response = await doFetch(webhook, { + method: "POST", + headers: {"content-type": "application/json"}, + body: JSON.stringify({content, allowed_mentions: {parse: []}}), + signal: AbortSignal.timeout(10_000), + }); + if (!response.ok) log.error("auth alert webhook rejected the message", {status: response.status}); + } catch (cause) { + log.error("auth alert webhook failed", {error: cause instanceof Error ? cause.message : String(cause)}); + } + } + + return { + fail(reason: string): void { + log.error(`stelo auth failure: ${reason}`); + if (alerted) return; + alerted = true; + void post(`⚠️ brico-bot cannot authenticate to Stelo: ${reason}. A human must log in again and replace the tokens.`); + }, + recover(): void { + if (!alerted) return; + alerted = false; + log.info("stelo auth recovered"); + void post("✅ brico-bot authenticated to Stelo again."); + }, + }; +} + +function createMemoryStore(): StateStore { + const memory = new Map(); + return { + async get(key) { + return memory.get(key) ?? null; + }, + async set(key, value) { + memory.set(key, value); + }, + async remove(key) { + const previous = memory.get(key) ?? null; + memory.delete(key); + return previous; + }, + async getAllKeys() { + return [...memory.keys()]; + }, + }; +} + +/** An oidc-client-ts user store backed by the id- and refresh-token files; any other key lives in memory. */ +function createFileUserStore(tokens: TokenStore): StateStore { + const memory = createMemoryStore(); + const isUser = (key: string) => key.startsWith("user:"); + return { + async get(key) { + if (!isUser(key)) return memory.get(key); + const idToken = tokens.read(ID_TOKEN_KEY); + const refreshToken = tokens.read(REFRESH_TOKEN_KEY); + if (idToken === undefined || refreshToken === undefined) return null; + const claims = decodeJwt(idToken); + return JSON.stringify({ + id_token: idToken, + refresh_token: refreshToken, + access_token: "", + token_type: "Bearer", + scope: SCOPE, + profile: claims, + expires_at: claims.exp, + }); + }, + async set(key, value) { + if (!isUser(key)) return memory.set(key, value); + const user = JSON.parse(value) as {id_token?: unknown; refresh_token?: unknown}; + if (typeof user.id_token !== "string" || typeof user.refresh_token !== "string") { + throw new Error("renewal did not return both an id token and a refresh token"); + } + // Refresh token first: it is one-time-use, so losing it strands the bot, while a stale id token only needs a renewal. + tokens.write(REFRESH_TOKEN_KEY, user.refresh_token); + tokens.write(ID_TOKEN_KEY, user.id_token); + }, + async remove(key) { + // Never delete the credential files; a human re-seeds them. + return isUser(key) ? null : memory.remove(key); + }, + getAllKeys: () => memory.getAllKeys(), + }; +} + +export function createSteloAuth(options: SteloAuthOptions): SteloAuth { + const {config} = options; + const log = options.log.child("stelo-auth"); + const alerter = createAlerter(config.authAlertWebhook, log, options.fetch ?? fetch); + const {auth} = config; + + const base = { + noteConnectError(cause: unknown): void { + if (isAuthRejection(cause)) alerter.fail(`Stelo rejected the token (${(cause as Error).message})`); + }, + noteConnected: () => alerter.recover(), + }; + + if (auth.mode === "permanent") { + return {...base, tokens: options.tokens, start() {}, stop() {}}; + } + + const {refreshIntervalMs} = auth; + const manager = new UserManager({ + authority: auth.authority, + client_id: auth.clientId, + // Required by the settings but unused: the bot never starts an interactive login. + redirect_uri: "http://localhost/unused", + response_type: "code", + scope: SCOPE, + automaticSilentRenew: false, + userStore: createFileUserStore(options.tokens), + stateStore: createMemoryStore(), + }); + + // Reads the file every dial, so a renewed (or hand-replaced) token is picked up on reconnect. + const tokens: TokenStore = { + read() { + const token = options.tokens.read(ID_TOKEN_KEY); + if (token === undefined) { + // Dialing without a token would connect anonymously as a new identity. + alerter.fail("no id token in the state dir"); + throw new Error("stelo id token is missing"); + } + return token; + }, + write() {}, + }; + + let timer: NodeJS.Timeout | null = null; + let stopped = false; + + /** Ms until the next renewal: the refresh interval after the last one, or sooner if the id token expires first. */ + function delayMs(): number { + const idToken = options.tokens.read(ID_TOKEN_KEY); + if (idToken === undefined) return RETRY_MS; + const {iat, exp} = decodeJwt(idToken); + const due = Math.min( + iat === undefined ? 0 : iat * 1000 + refreshIntervalMs, + exp === undefined ? Infinity : exp * 1000 - EXPIRY_MARGIN_MS, + ); + return Math.max(due - Date.now(), MIN_DELAY_MS); + } + + function schedule(delay: number): void { + if (stopped) return; + timer = setTimeout(() => void renew(), delay); + timer.unref(); + log.debug("next stelo token renewal", {inMs: delay}); + } + + async function renew(): Promise { + timer = null; + let next = RETRY_MS; + try { + if (options.tokens.read(ID_TOKEN_KEY) === undefined || options.tokens.read(REFRESH_TOKEN_KEY) === undefined) { + throw new Error("the id token or refresh token file is missing from the state dir"); + } + const user = await manager.signinSilent(); + if (!user?.id_token) throw new Error("renewal returned no user"); + log.info("renewed stelo tokens"); + alerter.recover(); + next = delayMs(); + } catch (cause) { + alerter.fail(`token renewal failed (${cause instanceof Error ? cause.message : String(cause)})`); + } + schedule(next); + } + + return { + ...base, + tokens, + start() { + // Empty files show a human where to paste the tokens; an empty file reads as missing. + for (const key of [ID_TOKEN_KEY, REFRESH_TOKEN_KEY]) { + if (options.tokens.read(key) === undefined) options.tokens.write(key, ""); + } + let first: number; + try { + first = delayMs(); + } catch (cause) { + alerter.fail(`stored id token is unreadable (${cause instanceof Error ? cause.message : String(cause)})`); + first = RETRY_MS; + } + schedule(first); + }, + stop() { + stopped = true; + if (timer) clearTimeout(timer); + timer = null; + }, + }; +} diff --git a/backend/brico-bot/src/stelo/books.ts b/backend/brico-bot/src/stelo/books.ts new file mode 100644 index 00000000..bc5bab98 --- /dev/null +++ b/backend/brico-bot/src/stelo/books.ts @@ -0,0 +1,35 @@ +/** + * books.ts — the bot's read-side view of the escrow books: what is pending, what is free to move. + * These mirror the module's own checks, so the bot never records an intent the module would reject. + */ +import type {SteloTransfer} from "@brico/bindings/brico-app/types"; +import type {Identity, Timestamp} from "spacetimedb"; + +import {type EscrowState, walletKey} from "../app/escrow-cache.ts"; + +/** Raw Stelo units for `amount` whole brico units on a ledger with `scale` decimal places. */ +export function toSteloAmount(amount: bigint, scale: number): bigint { + return amount * 10n ** BigInt(scale); +} + +export function isPending(transfer: SteloTransfer): boolean { + return transfer.state.tag === "Pending"; +} + +export function timestampMs(timestamp: Timestamp): number { + return Number(timestamp.microsSinceUnixEpoch / 1000n); +} + +/** Refund due for one paying wallet that is not already covered by a pending refund or reserved by a pending top-up. */ +export function reservableRefundDue(state: EscrowState, payer: Identity, currency: string, fundingAccountId: bigint): bigint { + const wallet = state.escrowWallets.get(walletKey(payer, currency, fundingAccountId)); + if (!wallet) return 0n; + let free = wallet.refundDue; + for (const transfer of state.transfers.values()) { + if (!isPending(transfer) || transfer.currency !== currency || !transfer.payerAccountIdentity.isEqual(payer)) continue; + const {purpose} = transfer; + if (purpose.tag === "Refund" && purpose.value.fundingAccountId === fundingAccountId) free -= transfer.amount; + else if (purpose.tag === "TopUp" && transfer.fromAccountId === fundingAccountId) free -= purpose.value.reserved; + } + return free > 0n ? free : 0n; +} diff --git a/backend/brico-bot/src/stelo/connection.ts b/backend/brico-bot/src/stelo/connection.ts new file mode 100644 index 00000000..74a0d158 --- /dev/null +++ b/backend/brico-bot/src/stelo/connection.ts @@ -0,0 +1,163 @@ +/** + * connection.ts — the bot's third SpacetimeDB connection, to Stelo, where escrowed bounty money + * lives. The only reducer it calls is `create_transfer`. + */ +import {DbConnection, type SubscriptionHandle, tables} from "@brico/bindings/stelo"; + +import type {SteloConfig} from "../config.ts"; +import type {Logger} from "../log.ts"; +import {createSupervisedConnection, type SupervisedConnection, type SupervisorOptions} from "../spacetime/connection.ts"; +import {attachFeed} from "../spacetime/feed.ts"; +import type {SteloAuth} from "./auth.ts"; +import type {SteloCache} from "./stelo-cache.ts"; + +/** The SDK types a procedure `Result` as `Ok | Err` but deserializes it as `{ok}` or `{err}`; this reads it as the latter. */ +function unwrapResult(result: unknown): {ok: Ok; err?: undefined} | {ok?: undefined; err: string} { + return result as {ok: Ok} | {err: string}; +} + +export interface SteloConnection { + readonly connection: SupervisedConnection | null; + readonly isLive: boolean; + /** Hex identity this connection authenticated as; Stelo's `create_app_ticket` / the dev seed need it. */ + readonly identityHex: string | null; + /** Sends one transfer (`pending = false`, memo and idempotency key both `ref`) and resolves when the reducer commits. */ + createTransfer(args: {fromAccountId: bigint; toAccountId: bigint; steloAmount: bigint; ref: string}): Promise; + /** Accounts on one ledger whose address starts with `address`, from Stelo's public directory (no membership needed). */ + searchAddress(address: string, ledgerId: bigint): Promise; + /** Up to 10 accounts on one ledger whose address or primary-user name starts with `term`; `scope` limits it to usernames or addresses. */ + searchAccounts(term: string, ledgerId: bigint, scope?: "Username" | "Address"): Promise; + /** One account's public directory entry, or `undefined` if it does not exist. */ + lookupAccount(accountId: bigint): Promise; + stop(): void; +} + +/** An account as Stelo's public directory shows it. */ +export interface SteloDirectoryHit { + accountId: bigint; + address: string; + ledgerId: bigint; + kind: {tag: string}; + primaryUsername: string | undefined; +} + +export interface SteloConnectionOptions extends Omit { + config: SteloConfig; + /** Supplies the dial token and is told about connect outcomes. */ + auth: SteloAuth; + cache: SteloCache; + /** Called whenever Stelo's rows change, so the Stelo step re-runs. */ + onRowsChanged(): void; +} + +export function startSteloConnection(options: SteloConnectionOptions): SteloConnection { + const log: Logger = options.log.child("stelo"); + const {target} = options.config; + const {cache} = options; + let identityHex: string | null = null; + + const connection = createSupervisedConnection( + { + label: target.label, + uri: target.uri, + database: target.database, + dial: handlers => + DbConnection.builder() + .withUri(target.uri) + .withDatabaseName(target.database) + .withToken(handlers.token) + .onConnect((conn, identity, token) => { + identityHex = identity.toHexString(); + options.auth.noteConnected(); + handlers.onConnect(conn, identity, token); + }) + .onDisconnect((_ctx, error) => handlers.onDisconnect(error)) + .onConnectError((_ctx, error) => { + options.auth.noteConnectError(error); + handlers.onConnectError(error); + }) + .build(), + onConnected: (conn, ctx) => { + let loaded = false; + const accepts = (callbackCtx: {event: {tag: string}}) => loaded && callbackCtx.event.tag !== "SubscribeApplied"; + const changed = options.onRowsChanged; + attachFeed(conn.db.myAccounts, cache.accounts, accepts, changed); + attachFeed(conn.db.myAccountsMembers, cache.members, accepts, changed); + attachFeed(conn.db.myTransfers, cache.transferRows, accepts, changed); + attachFeed(conn.db.user, cache.users, accepts, changed); + attachFeed(conn.db.ledger, cache.ledgers, accepts, changed); + + let subscription: SubscriptionHandle | null = conn + .subscriptionBuilder() + .onApplied(() => { + if (!ctx.isCurrent()) return; + cache.load(conn); + loaded = true; + ctx.setLive(); + changed(); + log.info("initial subscription applied", { + identity: identityHex ?? "?", + accounts: conn.db.myAccounts.count().toString(), + transfers: conn.db.myTransfers.count().toString(), + }); + }) + .onError(errorCtx => { + ctx.setError(`subscription error: ${errorCtx.event ? String(errorCtx.event) : "unknown"}`); + }) + .subscribe([tables.myAccounts, tables.myAccountsMembers, tables.myTransfers, tables.user, tables.ledger]); + + return () => { + loaded = false; + subscription?.unsubscribe(); + subscription = null; + }; + }, + }, + {tokens: options.auth.tokens, log: options.log, delayMs: options.delayMs, maxDelayMs: options.maxDelayMs}, + ); + + connection.start(); + + async function searchAccounts(term: string, ledgerId: bigint, scope?: "Username" | "Address"): Promise { + const conn = connection.connection; + if (!conn?.isActive || !connection.isLive) throw new Error("unavailable: stelo connection is not live"); + const result = unwrapResult(await conn.procedures.accountSearch({term, ledgerId, scope: scope && {tag: scope}})); + if (result.err !== undefined) throw new Error(result.err); + return result.ok; + } + + return { + connection, + get isLive() { + return connection.isLive; + }, + get identityHex() { + return identityHex; + }, + async createTransfer({fromAccountId, toAccountId, steloAmount, ref}) { + const conn = connection.connection; + if (!conn?.isActive || !connection.isLive) throw new Error("unavailable: stelo connection is not live"); + await conn.reducers.createTransfer({ + sendingAccountId: fromAccountId, + receivingAccountId: toAccountId, + amount: steloAmount, + memo: ref, + idempotencyKey: ref, + pending: false, + }); + }, + searchAddress: (address, ledgerId) => searchAccounts(address, ledgerId, "Address"), + searchAccounts, + async lookupAccount(accountId) { + const conn = connection.connection; + if (!conn?.isActive || !connection.isLive) throw new Error("unavailable: stelo connection is not live"); + const result = unwrapResult(await conn.procedures.accountLookup({accountId})); + if (result.err !== undefined) { + if (result.err === "account not found") return undefined; + throw new Error(result.err); + } + return result.ok; + }, + stop: () => connection.stop(), + }; +} diff --git a/backend/brico-bot/src/stelo/direct.ts b/backend/brico-bot/src/stelo/direct.ts new file mode 100644 index 00000000..ffcacda5 --- /dev/null +++ b/backend/brico-bot/src/stelo/direct.ts @@ -0,0 +1,178 @@ +/** + * direct.ts — wallet-to-wallet transfers between a payer's own linked wallet and any wallet + * (`/pay stelo`). A transfer is recorded as a `Direct` intent, which the transfer executor then + * sends and settles; it touches no escrow or entitlement books. + */ +import type {Identity} from "spacetimedb"; + +import type {EscrowCache} from "../app/escrow-cache.ts"; +import type {SteloConfig} from "../config.ts"; +import {toSteloAmount} from "./books.ts"; +import type {DirectoryAccount} from "./link-verify.ts"; +import {canFundFrom} from "./link-verify.ts"; +import type {SteloReadModel} from "./stelo-cache.ts"; +import type {SteloWrites} from "./writes.ts"; + +export interface DirectWallet { + accountId: bigint; + address: string; + primaryUsername: string | undefined; +} + +/** A validated transfer, ready to confirm and submit. */ +export interface DirectTransferPlan { + payer: Identity; + currency: string; + /** Whole brico units. */ + amount: bigint; + steloAmount: bigint; + from: DirectWallet; + to: DirectWallet; +} + +export type PrepareResult = {ok: true; plan: DirectTransferPlan} | {ok: false; reason: string}; + +/** How Stelo shows a wallet: `@user (#address)` for a user's primary account, otherwise `#address`. */ +export function walletLabel(wallet: {address: string; primaryUsername: string | undefined}): string { + return wallet.primaryUsername === undefined ? `#${wallet.address}` : `@${wallet.primaryUsername} (#${wallet.address})`; +} + +/** Splits a search box's text: `@` searches primary usernames, `#` addresses, anything else both. */ +function parseSearch(raw: string): {term: string; scope: "Username" | "Address" | undefined} { + const text = raw.trim(); + if (text.startsWith("@")) return {term: text.slice(1), scope: "Username"}; + if (text.startsWith("#")) return {term: text.slice(1), scope: "Address"}; + return {term: text, scope: undefined}; +} + +export type ResolveResult = {ok: true; accountId: bigint} | {ok: false; reason: string}; + +export interface DirectTransfers { + /** Wallets on `currency`'s ledger matching the search text (see `parseSearch`), excluding the bot's escrow wallets. */ + searchWallets(currency: string, text: string): Promise; + /** The one wallet whose address or primary username equals the search text; `reason` is user-facing. */ + resolveWallet(currency: string, text: string): Promise; + /** The payer's linked wallets in `currency` that the bot can currently send from. */ + sendableWallets(payer: Identity, currency: string): {accountId: bigint; address: string}[]; + /** Checks a transfer against the bot's live view of Stelo; `reason` is user-facing. */ + prepare(args: {payer: Identity; currency: string; fromAccountId: bigint; toAccountId: bigint; amount: bigint}): Promise; + /** Records the plan as a pending intent; rejects if the module refuses it or the connection is down. */ + submit(plan: DirectTransferPlan): Promise; +} + +export interface DirectTransferDeps { + config: SteloConfig; + escrow: EscrowCache; + /** BitCraft player id -> brico account, from active `bitcraft-ea2` links. */ + playerAccounts(): ReadonlyMap; + stelo: SteloReadModel & { + readonly isLive: boolean; + lookupAccount(accountId: bigint): Promise; + searchAccounts(term: string, ledgerId: bigint, scope?: "Username" | "Address"): Promise; + }; + writes: SteloWrites; +} + +export function createDirectTransfers(deps: DirectTransferDeps): DirectTransfers { + const {config, escrow, stelo, writes} = deps; + const escrowAccountIds = new Set(Object.values(config.currencies).map(currency => currency.escrowAccountId)); + + function sendableWallets(payer: Identity, currency: string): {accountId: bigint; address: string}[] { + const players = new Set(); + for (const [playerId, owner] of deps.playerAccounts()) if (owner.isEqual(payer)) players.add(playerId); + + const wallets: {accountId: bigint; address: string}[] = []; + for (const link of escrow.state.steloLinks.values()) { + if (!link.accountIdentity.isEqual(payer) || !/^\d+$/.test(link.externalId)) continue; + if (escrow.state.steloWallets.get(link.id)?.currency !== currency) continue; + const accountId = BigInt(link.externalId); + const account = stelo.account(accountId); + if (account && canFundFrom(stelo, accountId, players) === "ok") wallets.push({accountId, address: account.address}); + } + return wallets; + } + + async function searchWallets(currency: string, text: string): Promise { + const ledgerId = stelo.ledgerByName(config.currencies[currency]?.ledger ?? "")?.id; + const {term, scope} = parseSearch(text); + if (ledgerId === undefined || term === "" || !stelo.isLive) return []; + return (await stelo.searchAccounts(term, ledgerId, scope)).filter(hit => !escrowAccountIds.has(hit.accountId)); + } + + return { + sendableWallets, + searchWallets, + + async resolveWallet(currency, text) { + const {term, scope} = parseSearch(text); + if (term === "") return {ok: false, reason: "Enter the destination wallet."}; + let hits: DirectoryAccount[]; + try { + hits = await searchWallets(currency, text); + } catch { + return {ok: false, reason: "Couldn't reach Stelo to look up the wallet — try again in a moment."}; + } + const wanted = term.toLowerCase(); + const exact = hits.filter(hit => + (scope !== "Username" && hit.address.toLowerCase() === wanted) + || (scope !== "Address" && hit.primaryUsername?.toLowerCase() === wanted)); + if (exact.length === 1) return {ok: true, accountId: exact[0].accountId}; + return { + ok: false, + reason: exact.length === 0 + ? `No wallet matching "${text.trim()}" was found — pick one from the suggestions.` + : `"${text.trim()}" matches several wallets — start with @ for a user or # for an address, or pick one from the suggestions.`, + }; + }, + + async prepare({payer, currency, fromAccountId, toAccountId, amount}) { + if (!config.currencies[currency]) return {ok: false, reason: "That currency can't be sent through Stelo."}; + if (!stelo.isLive) return {ok: false, reason: "Stelo is temporarily unavailable — try again in a moment."}; + if (amount <= 0n) return {ok: false, reason: "The amount must be positive."}; + if (fromAccountId === toAccountId) return {ok: false, reason: "The two wallets are the same."}; + if (escrowAccountIds.has(fromAccountId) || escrowAccountIds.has(toAccountId)) { + return {ok: false, reason: "The bot's escrow wallets can't be used for direct transfers."}; + } + if (!sendableWallets(payer, currency).some(wallet => wallet.accountId === fromAccountId)) { + return {ok: false, reason: "That isn't one of your linked wallets the bot can send from."}; + } + + const fromAccount = stelo.account(fromAccountId); + if (!fromAccount) return {ok: false, reason: "That wallet isn't visible to the bot right now."}; + + let from: DirectoryAccount | undefined; + let to: DirectoryAccount | undefined; + try { + [from, to] = await Promise.all([stelo.lookupAccount(fromAccountId), stelo.lookupAccount(toAccountId)]); + } catch { + return {ok: false, reason: "Couldn't reach Stelo to look up the wallets — try again in a moment."}; + } + if (!to) return {ok: false, reason: "The destination wallet doesn't exist."}; + if (to.ledgerId !== fromAccount.ledgerId) return {ok: false, reason: "The destination wallet is on a different ledger."}; + + const steloAmount = toSteloAmount(amount, fromAccount.ledgerScale); + if ((stelo.balance(fromAccountId) ?? 0n) < steloAmount) { + return {ok: false, reason: "That wallet's balance doesn't cover the amount."}; + } + + return { + ok: true, + plan: { + payer, currency, amount, steloAmount, + from: {accountId: fromAccountId, address: fromAccount.address, primaryUsername: from?.primaryUsername}, + to: {accountId: toAccountId, address: to.address, primaryUsername: to.primaryUsername}, + }, + }; + }, + + submit: plan => writes.recordIntent({ + currency: plan.currency, + payer: plan.payer, + fromAccountId: plan.from.accountId, + toAccountId: plan.to.accountId, + amount: plan.amount, + steloAmount: plan.steloAmount, + purpose: {tag: "Direct", value: {}}, + }), + }; +} diff --git a/backend/brico-bot/src/stelo/executor.ts b/backend/brico-bot/src/stelo/executor.ts new file mode 100644 index 00000000..cdcdfaaa --- /dev/null +++ b/backend/brico-bot/src/stelo/executor.ts @@ -0,0 +1,103 @@ +/** + * executor.ts — carries each `Pending` transfer intent through Stelo exactly once. + * + * The intent row exists before any money moves, and its `ref` is both the memo and the idempotency + * key, so sending is safe to repeat: a replay returns the original transfer. An intent settles once + * the bot sees its posted transfer (ref, both accounts and raw amount must match) in `my_transfers`. + */ +import type {SteloTransfer} from "@brico/bindings/brico-app/types"; + +import type {Logger} from "../log.ts"; +import type {SteloReadModel} from "./stelo-cache.ts"; +import type {SteloWrites} from "./writes.ts"; + +export type FailureKind = "insufficient-funds" | "no-permission" | "invalid-account" | "conflict"; + +/** Maps a rejected `create_transfer` to a terminal failure, or `null` for anything worth retrying (outages, timeouts). */ +export function classifyTransferError(cause: unknown): FailureKind | null { + const message = (cause instanceof Error ? cause.message : String(cause)).toLowerCase(); + if (message.includes("invalid balance")) return "insufficient-funds"; + if (message.includes("insufficient account permission")) return "no-permission"; + if (message.includes("account not found") || message.includes("incompatible account ledgers")) return "invalid-account"; + if (message.includes("idempotency key conflict")) return "conflict"; + return null; +} + +export interface ExecutorDeps { + log: Logger; + stelo: SteloReadModel & { + readonly isLive: boolean; + createTransfer(args: {fromAccountId: bigint; toAccountId: bigint; steloAmount: bigint; ref: string}): Promise; + }; + writes: SteloWrites; + now(): number; + /** How long to wait for a sent transfer to echo back before sending it again (safe: same idempotency key). */ + resendAfterMs?: number; +} + +export interface TransferExecutor { + /** Advances every pending intent one step. Safe to call every tick. */ + run(pending: Iterable): void; + /** While true, no new transfer is sent (settling already-posted ones continues). */ + paused: boolean; +} + +export function createTransferExecutor(deps: ExecutorDeps): TransferExecutor { + const {log, stelo, writes} = deps; + const resendAfterMs = deps.resendAfterMs ?? 30_000; + // Intent id -> earliest time to attempt a send again. + const nextAttempt = new Map(); + const settling = new Set(); + const failing = new Set(); + + const executor: TransferExecutor = { + paused: false, + run(pending) { + const live = new Set(); + for (const intent of pending) { + live.add(intent.id); + if (settling.has(intent.id) || failing.has(intent.id)) continue; + + const posted = stelo.findPostedTransfer({ + ref: intent.ref, fromAccountId: intent.fromAccountId, toAccountId: intent.toAccountId, steloAmount: intent.steloAmount, + }); + if (posted) { + settling.add(intent.id); + writes.settleTransfer(intent.id, posted.id) + .catch(cause => log.error("settle_stelo_transfer failed", {intent: intent.ref, error: describe(cause)})) + .finally(() => settling.delete(intent.id)); + continue; + } + + if (executor.paused || !stelo.isLive) continue; + if (deps.now() < (nextAttempt.get(intent.id) ?? 0)) continue; + nextAttempt.set(intent.id, deps.now() + resendAfterMs); + send(intent); + } + for (const id of nextAttempt.keys()) if (!live.has(id)) nextAttempt.delete(id); + }, + }; + + function send(intent: SteloTransfer): void { + stelo.createTransfer({ + fromAccountId: intent.fromAccountId, toAccountId: intent.toAccountId, steloAmount: intent.steloAmount, ref: intent.ref, + }).catch(cause => { + const kind = classifyTransferError(cause); + if (kind === null) { + log.warn("transfer send failed; will retry", {intent: intent.ref, error: describe(cause)}); + return; + } + log.warn("transfer rejected", {intent: intent.ref, reason: kind, error: describe(cause)}); + failing.add(intent.id); + writes.failTransfer(intent.id, kind) + .catch(failure => log.error("fail_stelo_transfer failed", {intent: intent.ref, error: describe(failure)})) + .finally(() => failing.delete(intent.id)); + }); + } + + return executor; +} + +function describe(cause: unknown): string { + return cause instanceof Error ? cause.message : String(cause); +} diff --git a/backend/brico-bot/src/stelo/funding-status.ts b/backend/brico-bot/src/stelo/funding-status.ts new file mode 100644 index 00000000..bb46b625 --- /dev/null +++ b/backend/brico-bot/src/stelo/funding-status.ts @@ -0,0 +1,119 @@ +/** + * funding-status.ts — keeps each escrowed-currency craft's `craft_bounty_funding` row current and + * raises a funding alert when a row lands in a state the payer can act on (the notifier collapses + * repeats) and tells the notifier when a craft becomes funded. + */ +import type {CraftBountyFunding} from "@brico/bindings/brico-app/types"; +import {ACTIONABLE_FUNDING_REASONS} from "@brico/crafts/stelo"; +import type {Identity} from "spacetimedb"; + +import type {EscrowCache} from "../app/escrow-cache.ts"; +import type {Logger} from "../log.ts"; +import type {FundingStatus} from "./funding.ts"; +import {createInFlight} from "./inflight.ts"; +import type {FundingNotifier} from "./notify.ts"; +import type {FundingRow, SteloWrites} from "./writes.ts"; + +/** What the engine resolved for a craft, for the row's non-status columns. */ +export interface FundingSubject { + source: "override" | "rule"; + ruleId: string | undefined; + currency: string; + payer: Identity; + fundingLinkId: bigint | undefined; + ratioNumerator: bigint; + ratioDenominator: bigint; +} + +export interface FundingStatusSync { + /** Records the latest status for a craft; the row is written when it differs from the stored one. */ + report(craftId: bigint, subject: FundingSubject, status: FundingStatus): void; + /** The craft no longer has an escrowed-currency intent. */ + clear(craftId: bigint): void; + /** Writes whatever is outstanding; called every tick so a failed or skipped write is retried. */ + flush(): void; +} + +export interface FundingStatusDeps { + escrow: EscrowCache; + writes: SteloWrites; + notifier: FundingNotifier | undefined; + log: Logger; + /** How long a write blocks another for the same craft after it settles; see `createInFlight`. */ + holdMs?: number; +} + +function sameRow(stored: CraftBountyFunding, row: FundingRow): boolean { + return stored.payerAccountIdentity.isEqual(row.payer) && stored.source === row.source && stored.ruleId === row.ruleId + && stored.currency === row.currency && stored.fundingLinkId === row.fundingLinkId + && stored.ratioNumerator === row.ratioNumerator && stored.ratioDenominator === row.ratioDenominator + && stored.state === row.state && stored.reason === row.reason && stored.transferId === row.transferId; +} + +/** `failed`, or `waiting` with a reason the payer can act on. */ +function isAlerting(row: FundingRow): boolean { + return row.state === "failed" || (row.state === "waiting" && row.reason !== undefined && ACTIONABLE_FUNDING_REASONS.includes(row.reason)); +} + +export function createFundingStatusSync(deps: FundingStatusDeps): FundingStatusSync { + const {escrow, writes, notifier} = deps; + const inFlight = createInFlight(deps.log, deps.holdMs); + // The row each craft should have, or null for no row. + const desired = new Map(); + + function flushOne(craftId: bigint, row: FundingRow | null): void { + const stored = escrow.state.fundingRows.get(craftId); + const key = `funding-status:${craftId}`; + if (row === null) { + if (!stored) return void desired.delete(craftId); + if (!inFlight.has(key)) inFlight.track(key, writes.clearFunding(craftId), "clear craft funding", {craftId: craftId.toString()}); + return; + } + if (stored && sameRow(stored, row)) return void desired.delete(craftId); + if (inFlight.has(key)) return; + const alerting = isAlerting(row); + const recovered = row.state === "funded" && stored?.state !== "funded"; + const write = writes.setFunding(row); + inFlight.track(key, write, "set craft funding", {craftId: craftId.toString()}); + if (notifier && (alerting || recovered)) { + write.then(() => { + if (recovered) notifier.resolve("funding-failed", row.payer, row.currency, wallet(row.fundingLinkId)); + // Not tied to a craft: the notifier posts once per wallet and reason, however many crafts share them. + else notifier.notify({kind: "funding-failed", role: "payer", account: row.payer, currency: row.currency, reason: row.reason, walletAccountId: wallet(row.fundingLinkId)}); + }).catch(() => {}); + } + } + + function wallet(linkId: bigint | undefined): bigint | undefined { + const link = linkId === undefined ? undefined : escrow.state.steloLinks.get(linkId); + return link && /^\d+$/.test(link.externalId) ? BigInt(link.externalId) : undefined; + } + + return { + report(craftId, subject, status) { + const row: FundingRow = { + craftId, + payer: subject.payer, + source: subject.source, + ruleId: subject.ruleId, + currency: subject.currency, + fundingLinkId: subject.fundingLinkId, + ratioNumerator: subject.ratioNumerator, + ratioDenominator: subject.ratioDenominator, + state: status.state, + reason: status.reason, + transferId: status.transferId, + }; + desired.set(craftId, row); + flushOne(craftId, row); + }, + clear(craftId) { + if (!escrow.state.fundingRows.has(craftId) && !desired.has(craftId)) return; + desired.set(craftId, null); + flushOne(craftId, null); + }, + flush() { + for (const [craftId, row] of [...desired]) flushOne(craftId, row); + }, + }; +} diff --git a/backend/brico-bot/src/stelo/funding.ts b/backend/brico-bot/src/stelo/funding.ts new file mode 100644 index 00000000..44e12cff --- /dev/null +++ b/backend/brico-bot/src/stelo/funding.ts @@ -0,0 +1,244 @@ +/** + * funding.ts — the funding gate: before an escrowed bounty is assigned, the money behind it must be + * held. Each call either confirms the craft's hold covers the bounty, or drives the transfer that + * will (a hold, or a top-up for a raise) and tells the caller to wait. + */ +import type {CraftBountyEscrow, SteloTransfer} from "@brico/bindings/brico-app/types"; +import {ceilRatio, escrowRequired, reduceRatio} from "@brico/crafts/entitlement"; +import {FUNDING_REASONS, type FundingReason, type FundingState} from "@brico/crafts/stelo"; +import type {Identity} from "spacetimedb"; +import type {EscrowCache} from "../app/escrow-cache.ts"; + +import type {SteloConfig} from "../config.ts"; +import type {Logger} from "../log.ts"; +import {isPending, reservableRefundDue, timestampMs, toSteloAmount} from "./books.ts"; +import {createInFlight} from "./inflight.ts"; +import {canFundFrom} from "./link-verify.ts"; +import type {SteloReadModel} from "./stelo-cache.ts"; +import type {SteloWrites} from "./writes.ts"; + +/** What the engine resolved for a craft and wants assigned. */ +export interface FundingRequest { + ratioNumerator: bigint; + ratioDenominator: bigint; + currency: string; + payer: Identity; + /** The rule's or override's paying wallet (`linked_integration.id`). */ + fundingLinkId: bigint | undefined; +} + +/** + * - `ready`: the craft's hold secures the requested ratio; assign it. + * - `keep`: the craft is held for a different payer or currency; leave its assignment as it is. + * - `wait`: not funded (yet); do not assign. + */ +export type FundingDecision = "ready" | "keep" | "wait"; + +/** Why a craft's escrowed bounty is or isn't funded, as shown to its payer. */ +export interface FundingStatus { + state: FundingState; + reason?: FundingReason; + /** The in-flight or last failed hold/top-up. */ + transferId?: bigint; +} + +export interface FundingGate { + /** The craft's funding status for `request`, as of the latest `prepare` call. */ + status(craftId: bigint, request: FundingRequest): FundingStatus; + /** + * `effortCovered` is the effort a fresh hold must cover (the craft's total effort minus what the + * assignment will protect as pre-bounty). Safe to call every tick: at most one hold or top-up + * is in flight per craft, and failures back off. + */ + prepare(craftId: bigint, request: FundingRequest, effortCovered: bigint): FundingDecision; +} + +export interface FundingDeps { + config: SteloConfig; + escrow: EscrowCache; + stelo: SteloReadModel & {readonly isLive: boolean}; + /** BitCraft player id -> brico account, from active `bitcraft-ea2` links. */ + playerAccounts(): ReadonlyMap; + writes: SteloWrites; + log: Logger; + now(): number; +} + +export function createFundingGate(deps: FundingDeps): FundingGate { + const {config, escrow, stelo, writes, log} = deps; + const inFlight = createInFlight(log); + + /** Whether the bot may pull from `accountId` for `payer`: it holds Write and a linked player of theirs still administers the wallet. */ + function pullCheck(payer: Identity, accountId: bigint): "ok" | "cannot-pull" | "not-authorized" { + const players = new Set(); + for (const [playerId, owner] of deps.playerAccounts()) if (owner.isEqual(payer)) players.add(playerId); + return canFundFrom(stelo, accountId, players); + } + // `craft:reason` pairs already logged, so a stuck craft doesn't log every tick. + const reported = new Set(); + // Why each craft's latest `prepare` could not proceed. + const waiting = new Map(); + + function report(craftId: bigint, reason: FundingReason, extra: Record = {}): void { + waiting.set(craftId, reason); + const key = `${craftId}:${reason}`; + if (reported.has(key)) return; + reported.add(key); + log.info(`funding waiting: ${reason}`, {craftId: craftId.toString(), ...extra}); + } + + const track = (key: string, call: Promise, what: string, craftId: bigint) => + inFlight.track(key, call, what, {craftId: craftId.toString()}); + + /** Failed holds/top-ups of this craft: how many, and the latest one. */ + function failuresOf(craftId: bigint, tag: "Hold" | "TopUp"): {failures: number; latestAt: number; latest: SteloTransfer | undefined} { + let failures = 0; + let latestAt = 0; + let latest: SteloTransfer | undefined; + for (const transfer of escrow.state.transfers.values()) { + if (transfer.state.tag !== "Failed" || transfer.purpose.tag !== tag || transfer.purpose.value.craftId !== craftId) continue; + failures += 1; + const at = transfer.settledAt ? timestampMs(transfer.settledAt) : 0; + if (latest === undefined || at >= latestAt) { + latestAt = at; + latest = transfer; + } + } + return {failures, latestAt, latest}; + } + + /** Exponential backoff over the failed holds/top-ups, which survives a restart. */ + function backoffUntil(craftId: bigint, tag: "Hold" | "TopUp"): number { + const {failures, latestAt} = failuresOf(craftId, tag); + if (failures === 0) return 0; + return latestAt + Math.min(config.retryBaseMs * 2 ** (failures - 1), config.retryMaxMs); + } + + function pendingTransfer(craftId: bigint, tag: "Hold" | "TopUp"): SteloTransfer | undefined { + for (const transfer of escrow.state.transfers.values()) { + if (isPending(transfer) && transfer.purpose.tag === tag && transfer.purpose.value.craftId === craftId) return transfer; + } + return undefined; + } + + function hasPending(craftId: bigint, tag: "Hold" | "TopUp"): boolean { + for (const transfer of escrow.state.transfers.values()) { + if (isPending(transfer) && transfer.purpose.tag === tag && transfer.purpose.value.craftId === craftId) return true; + } + return false; + } + + function raise(craftId: bigint, held: CraftBountyEscrow, request: FundingRequest): void { + const key = `topup:${craftId}`; + if (inFlight.has(key) || hasPending(craftId, "TopUp")) return; + if (deps.now() < backoffUntil(craftId, "TopUp")) return; + + const ratio = {numerator: request.ratioNumerator, denominator: request.ratioDenominator}; + const need = escrowRequired( + {numerator: held.accruedNumerator, denominator: held.accruedDenominator}, + ratio, held.effortCovered, held.effortPriced, + ) - (held.heldAmount - held.releasedAmount); + + const reservable = reservableRefundDue(escrow.state, held.payerAccountIdentity, held.currency, held.fundingAccountId); + if (need <= 0n || reservable >= need) { + track(key, writes.raiseFromRefundDue(craftId, ratio.numerator, ratio.denominator), "raise from refund due", craftId); + return; + } + if (!stelo.isLive) return report(craftId, "stelo-unavailable"); + const allowed = pullCheck(held.payerAccountIdentity, held.fundingAccountId); + if (allowed !== "ok") return report(craftId, allowed, {account: held.fundingAccountId.toString()}); + + const escrowAccountId = config.currencies[held.currency]?.escrowAccountId; + const scale = stelo.account(held.fundingAccountId)?.ledgerScale; + if (escrowAccountId === undefined || scale === undefined) return; + const reserved = reservable; + const amount = need - reserved; + track(key, writes.recordIntent({ + currency: held.currency, + payer: held.payerAccountIdentity, + fromAccountId: held.fundingAccountId, + toAccountId: escrowAccountId, + amount, + steloAmount: toSteloAmount(amount, scale), + purpose: {tag: "TopUp", value: {craftId, ratioNumerator: ratio.numerator, ratioDenominator: ratio.denominator, reserved}}, + }), "top-up intent", craftId); + } + + function hold(craftId: bigint, request: FundingRequest, effortCovered: bigint): void { + const key = `hold:${craftId}`; + if (inFlight.has(key) || hasPending(craftId, "Hold")) return; + if (deps.now() < backoffUntil(craftId, "Hold")) return; + + if (!stelo.isLive) return report(craftId, "stelo-unavailable"); + if (request.fundingLinkId === undefined) return report(craftId, "no-wallet"); + const wallet = escrow.state.steloWallets.get(request.fundingLinkId); + const link = escrow.state.steloLinks.get(request.fundingLinkId); + if (!wallet || !link || wallet.currency !== request.currency || !link.accountIdentity.isEqual(request.payer)) { + return report(craftId, "wallet-inactive", {link: request.fundingLinkId.toString()}); + } + const fundingAccountId = BigInt(link.externalId); + const allowed = pullCheck(request.payer, fundingAccountId); + if (allowed !== "ok") return report(craftId, allowed, {account: fundingAccountId.toString()}); + if (effortCovered <= 0n) return; + + const escrowAccountId = config.currencies[request.currency]?.escrowAccountId; + const scale = stelo.account(fundingAccountId)?.ledgerScale; + if (escrowAccountId === undefined || scale === undefined) return; + const amount = ceilRatio(reduceRatio(request.ratioNumerator * effortCovered, request.ratioDenominator)); + if (amount <= 0n) return; + track(key, writes.recordIntent({ + currency: request.currency, + payer: request.payer, + fromAccountId: fundingAccountId, + toAccountId: escrowAccountId, + amount, + steloAmount: toSteloAmount(amount, scale), + purpose: { + tag: "Hold", + value: { + craftId, + fundingLinkId: request.fundingLinkId, + ratioNumerator: request.ratioNumerator, + ratioDenominator: request.ratioDenominator, + effortCovered, + }, + }, + }), "hold intent", craftId); + } + + const securedBy = (held: CraftBountyEscrow, request: FundingRequest) => + request.ratioNumerator * held.ratioDenominator <= held.ratioNumerator * request.ratioDenominator; + + return { + status(craftId, request) { + const held = escrow.state.openEscrows.get(craftId); + const tag = held ? "TopUp" : "Hold"; + if (held && (held.currency !== request.currency || !held.payerAccountIdentity.isEqual(request.payer))) return {state: "waiting"}; + if (held && securedBy(held, request)) return {state: "funded"}; + + const inFlightTransfer = pendingTransfer(craftId, tag); + if (inFlightTransfer) return {state: "funding", transferId: inFlightTransfer.id}; + const {latest} = failuresOf(craftId, tag); + if (latest && deps.now() < backoffUntil(craftId, tag)) { + const reason = FUNDING_REASONS.find(code => code === latest.failureReason); + return {state: "failed", reason, transferId: latest.id}; + } + return {state: "waiting", reason: waiting.get(craftId)}; + }, + + prepare(craftId, request, effortCovered) { + waiting.delete(craftId); + const held = escrow.state.openEscrows.get(craftId); + if (held) { + if (held.currency !== request.currency || !held.payerAccountIdentity.isEqual(request.payer)) return "keep"; + const requested = request.ratioNumerator * held.ratioDenominator; + const secured = held.ratioNumerator * request.ratioDenominator; + if (requested <= secured) return "ready"; + raise(craftId, held, request); + return "wait"; + } + hold(craftId, request, effortCovered); + return "wait"; + }, + }; +} diff --git a/backend/brico-bot/src/stelo/inflight.ts b/backend/brico-bot/src/stelo/inflight.ts new file mode 100644 index 00000000..a95062aa --- /dev/null +++ b/backend/brico-bot/src/stelo/inflight.ts @@ -0,0 +1,24 @@ +/** + * inflight.ts — keeps a reducer call from being repeated before its effect has echoed back through + * the cache, which the next tick would otherwise read as "not done yet". + */ +import type {Logger} from "../log.ts"; + +export interface InFlight { + has(key: string): boolean; + /** Runs `call`'s bookkeeping: `key` stays marked until shortly after the call settles; a rejection is logged, never thrown. */ + track(key: string, call: Promise, what: string, fields?: Record): void; +} + +export function createInFlight(log: Logger, holdMs = 2000): InFlight { + const keys = new Set(); + return { + has: key => keys.has(key), + track(key, call, what, fields = {}) { + keys.add(key); + call + .catch(cause => log.error(`${what} failed`, {...fields, error: cause instanceof Error ? cause.message : String(cause)})) + .finally(() => setTimeout(() => keys.delete(key), holdMs).unref()); + }, + }; +} diff --git a/backend/brico-bot/src/stelo/link-verify.test.ts b/backend/brico-bot/src/stelo/link-verify.test.ts new file mode 100644 index 00000000..cf043ed4 --- /dev/null +++ b/backend/brico-bot/src/stelo/link-verify.test.ts @@ -0,0 +1,104 @@ +import assert from "node:assert/strict"; +import {describe, it} from "node:test"; + +import type {SteloCurrencyConfig} from "../config.ts"; +import {canFundFrom, canPull, type DirectoryAccount, matchDirectoryHits, verifySteloAddress, verifySteloLink} from "./link-verify.ts"; +import {createSteloCache} from "./stelo-cache.ts"; + +const currencies: Record = { + "stelo-hex-coin": {ledger: "Hex Coin", escrowAccountId: 900n, unclaimed: {action: "refund", afterDays: 7}}, +}; +const hex = (value: string) => ({toHexString: () => value}); + +function world(overrides: {kind?: string; ledgerName?: string; botRole?: string; memberRole?: string; memberKind?: string; playerId?: bigint} = {}) { + const cache = createSteloCache(); + cache.accounts.insert({ + accountId: 101n, address: "ABCD", kind: {tag: overrides.kind ?? "Debit"}, balance: 0n, ledgerId: 1n, + ledgerName: overrides.ledgerName ?? "Hex Coin", ledgerScale: 3, role: {tag: overrides.botRole ?? "Read"}, + }); + cache.members.insert({ + id: 1n, accountId: 101n, memberId: hex("user-a"), kind: {tag: overrides.memberKind ?? "User"}, role: {tag: overrides.memberRole ?? "Owner"}, + }); + cache.users.insert({id: hex("user-a"), bitcraftPlayerId: overrides.playerId ?? 5001n}); + return cache; +} + +describe("verifySteloLink", () => { + const players = new Set([5001n]); + + it("accepts a Debit wallet on a configured ledger owned by a linked player, with only Read", () => { + assert.deepEqual(verifySteloLink(101n, players, world(), currencies), {ok: true, accountId: 101n, address: "ABCD", currency: "stelo-hex-coin"}); + }); + + it("resolves a submitted address case-insensitively, and rejects an unknown or cross-ledger ambiguous one", () => { + const cache = world(); + assert.deepEqual(verifySteloAddress("abcd", players, cache, currencies), {ok: true, accountId: 101n, address: "ABCD", currency: "stelo-hex-coin"}); + assert.deepEqual(verifySteloAddress("ZZ", players, cache, currencies), {ok: false, reason: "bot-not-member"}); + + const two: Record = {...currencies, "other-coin": {ledger: "Other", escrowAccountId: 901n, unclaimed: {action: "refund", afterDays: 7}}}; + cache.accounts.insert({accountId: 202n, address: "ABCD", kind: {tag: "Debit"}, balance: 0n, ledgerId: 2n, ledgerName: "Other", ledgerScale: 0, role: {tag: "Read"}}); + assert.deepEqual(verifySteloAddress("ABCD", players, cache, two), {ok: false, reason: "ambiguous-address"}); + assert.equal(verifySteloAddress("ABCD", players, cache, currencies).ok, true); + }); + + it("rejects an account the bot cannot see", () => { + assert.deepEqual(verifySteloLink(7n, players, world(), currencies), {ok: false, reason: "bot-not-member"}); + }); + + it("rejects Credit accounts, other ledgers and the escrow wallet itself", () => { + assert.deepEqual(verifySteloLink(101n, players, world({kind: "Credit"}), currencies), {ok: false, reason: "not-debit"}); + assert.deepEqual(verifySteloLink(101n, players, world({ledgerName: "Other"}), currencies), {ok: false, reason: "wrong-ledger"}); + assert.deepEqual( + verifySteloLink(101n, players, world(), {"stelo-hex-coin": {...currencies["stelo-hex-coin"], escrowAccountId: 101n}}), + {ok: false, reason: "escrow-account"}, + ); + }); + + it("needs a linked BitCraft player and an Owner/Admin User member matching one", () => { + assert.deepEqual(verifySteloLink(101n, new Set(), world(), currencies), {ok: false, reason: "no-linked-bitcraft-player"}); + assert.deepEqual(verifySteloLink(101n, players, world({memberRole: "Write"}), currencies), {ok: false, reason: "no-owner-admin-match"}); + assert.deepEqual(verifySteloLink(101n, players, world({memberKind: "App"}), currencies), {ok: false, reason: "no-owner-admin-match"}); + assert.deepEqual(verifySteloLink(101n, players, world({playerId: 6n}), currencies), {ok: false, reason: "no-owner-admin-match"}); + assert.equal(verifySteloLink(101n, players, world({memberRole: "Admin"}), currencies).ok, true); + }); +}); + +describe("canPull", () => { + it("needs Write or better on the account", () => { + assert.equal(canPull(world({botRole: "Read"}), 101n), false); + assert.equal(canPull(world({botRole: "Write"}), 101n), true); + assert.equal(canPull(world({botRole: "Admin"}), 101n), true); + assert.equal(canPull(world(), 999n), false); + }); +}); + +describe("canFundFrom", () => { + const players = new Set([5001n]); + + it("needs Write and a still-administering linked player", () => { + assert.equal(canFundFrom(world({botRole: "Write"}), 101n, players), "ok"); + assert.equal(canFundFrom(world({botRole: "Read"}), 101n, players), "cannot-pull"); + assert.equal(canFundFrom(world({botRole: "Write", memberRole: "Write"}), 101n, players), "not-authorized"); + assert.equal(canFundFrom(world({botRole: "Write"}), 101n, new Set([6n])), "not-authorized"); + }); +}); + +describe("matchDirectoryHits", () => { + const ledgerName = (id: bigint) => (id === 1n ? "Hex Coin" : "Other"); + const hit = (overrides: Partial = {}): DirectoryAccount => ({ + accountId: 101n, address: "ABCD", ledgerId: 1n, kind: {tag: "Debit"}, primaryUsername: "Jo", ...overrides, + }); + + it("picks the one exact address match on a configured ledger, with its username", () => { + assert.deepEqual(matchDirectoryHits("abcd", [hit(), hit({accountId: 5n, address: "ABCDE"})], currencies, ledgerName), { + ok: true, accountId: 101n, address: "ABCD", currency: "stelo-hex-coin", username: "Jo", + }); + }); + + it("rejects unknown, other-ledger, credit and escrow accounts", () => { + assert.deepEqual(matchDirectoryHits("ZZ", [hit()], currencies, ledgerName), {ok: false, reason: "unknown-address"}); + assert.deepEqual(matchDirectoryHits("ABCD", [hit({ledgerId: 2n})], currencies, ledgerName), {ok: false, reason: "wrong-ledger"}); + assert.deepEqual(matchDirectoryHits("ABCD", [hit({kind: {tag: "Credit"}})], currencies, ledgerName), {ok: false, reason: "not-debit"}); + assert.deepEqual(matchDirectoryHits("ABCD", [hit({accountId: 900n})], currencies, ledgerName), {ok: false, reason: "escrow-account"}); + }); +}); diff --git a/backend/brico-bot/src/stelo/link-verify.ts b/backend/brico-bot/src/stelo/link-verify.ts new file mode 100644 index 00000000..d30d3f74 --- /dev/null +++ b/backend/brico-bot/src/stelo/link-verify.ts @@ -0,0 +1,130 @@ +/** + * link-verify.ts — decides whether a Stelo account a browser claims to control may be linked as a + * brico payout/funding wallet. + */ +import type {SteloCurrencyConfig} from "../config.ts"; +import {roleAtLeast, type SteloReadModel} from "./stelo-cache.ts"; + +export type LinkFailure = + | "bot-not-member" + | "ambiguous-address" + | "not-debit" + | "wrong-ledger" + | "escrow-account" + | "no-linked-bitcraft-player" + | "no-owner-admin-match"; + +export type LinkVerdict = {ok: true; accountId: bigint; address: string; currency: string} | {ok: false; reason: LinkFailure}; + +/** + * Resolves the address a browser submitted to one account id, then verifies it like `verifySteloLink`. + * An address is unique per ledger only, so one that matches accounts on several configured ledgers is + * `ambiguous-address`. The bot only sees accounts it belongs to, so an unknown address and one the bot + * is not a member of are indistinguishable (`bot-not-member`). + */ +export function verifySteloAddress( + address: string, + linkedPlayerIds: ReadonlySet, + stelo: SteloReadModel, + currencies: Readonly>, +): LinkVerdict { + const matches = stelo.accountsByAddress(address).filter(account => roleAtLeast(account.role, "Read")); + if (matches.length === 0) return {ok: false, reason: "bot-not-member"}; + let chosen = matches; + if (matches.length > 1) { + const ledgers = new Set(Object.values(currencies).map(config => config.ledger)); + chosen = matches.filter(account => ledgers.has(account.ledgerName)); + if (chosen.length !== 1) return {ok: false, reason: "ambiguous-address"}; + } + return verifySteloLink(chosen[0].accountId, linkedPlayerIds, stelo, currencies); +} + +/** + * Passes when the bot is a member of `accountId` (Read is enough: only a paying wallet ever needs + * Write, checked live by `canPull`), the account is a Debit account on a configured escrow + * currency's ledger and is not an escrow wallet itself, and some member with kind User and role + * Owner or Admin has a Stelo `bitcraftPlayerId` among the linking brico account's active + * `bitcraft-ea2` players. The bot only sees accounts it belongs to, so a missing account and a + * non-member one are indistinguishable (`bot-not-member`). + */ +export function verifySteloLink( + accountId: bigint, + linkedPlayerIds: ReadonlySet, + stelo: SteloReadModel, + currencies: Readonly>, +): LinkVerdict { + const account = stelo.account(accountId); + if (!account || !roleAtLeast(account.role, "Read")) return {ok: false, reason: "bot-not-member"}; + if (account.kind.tag !== "Debit") return {ok: false, reason: "not-debit"}; + + const currency = Object.keys(currencies).find(id => currencies[id].ledger === account.ledgerName); + if (currency === undefined) return {ok: false, reason: "wrong-ledger"}; + if (Object.values(currencies).some(config => config.escrowAccountId === accountId)) return {ok: false, reason: "escrow-account"}; + + if (linkedPlayerIds.size === 0) return {ok: false, reason: "no-linked-bitcraft-player"}; + return ownsAccount(stelo, accountId, linkedPlayerIds) + ? {ok: true, accountId, address: account.address, currency} + : {ok: false, reason: "no-owner-admin-match"}; +} + +/** Whether some User member with role Admin or Owner of `accountId` has a Stelo `bitcraftPlayerId` among `linkedPlayerIds`. */ +export function ownsAccount(stelo: SteloReadModel, accountId: bigint, linkedPlayerIds: ReadonlySet): boolean { + return stelo.membersOf(accountId).some(member => { + if (member.kind.tag !== "User" || !roleAtLeast(member.role, "Admin")) return false; + const user = stelo.userByIdentity(member.memberId.toHexString()); + return user !== undefined && linkedPlayerIds.has(user.bitcraftPlayerId); + }); +} + +/** Whether the bot can currently pull funds from `accountId` (role Write or above). Evaluated live, never cached on the link. */ +export function canPull(stelo: SteloReadModel, accountId: bigint): boolean { + return roleAtLeast(stelo.account(accountId)?.role, "Write"); +} + +/** + * Whether the bot may pull from `accountId` for the brico account whose BitCraft players are + * `linkedPlayerIds`: it holds Write and the linker still administers the wallet. Evaluated live + * before every pull, so a member removed from a shared wallet stops being able to spend from it. + */ +export function canFundFrom(stelo: SteloReadModel, accountId: bigint, linkedPlayerIds: ReadonlySet): "ok" | "cannot-pull" | "not-authorized" { + if (!canPull(stelo, accountId)) return "cannot-pull"; + return ownsAccount(stelo, accountId, linkedPlayerIds) ? "ok" : "not-authorized"; +} + +/** An account as Stelo's public directory lists it. */ +export interface DirectoryAccount { + accountId: bigint; + address: string; + ledgerId: bigint; + kind: {tag: string}; + primaryUsername: string | undefined; +} + +export type UnverifiedMatch = + | {ok: true; accountId: bigint; address: string; currency: string; username: string | undefined} + | {ok: false; reason: "unknown-address" | "ambiguous-address" | "not-debit" | "wrong-ledger" | "escrow-account"}; + +/** + * Picks the one account an unverified link points at from directory search `hits` (one search per + * configured ledger): the exact address match on a configured escrow currency's ledger. No ownership + * is checked, so the result can only ever be used to receive payouts. + */ +export function matchDirectoryHits( + address: string, + hits: readonly DirectoryAccount[], + currencies: Readonly>, + ledgerName: (ledgerId: bigint) => string | undefined, +): UnverifiedMatch { + const wanted = address.toUpperCase(); + const exact = hits.filter(hit => hit.address.toUpperCase() === wanted); + if (exact.length === 0) return {ok: false, reason: "unknown-address"}; + const configured = exact.filter(hit => Object.values(currencies).some(config => config.ledger === ledgerName(hit.ledgerId))); + if (configured.length === 0) return {ok: false, reason: "wrong-ledger"}; + if (configured.length > 1) return {ok: false, reason: "ambiguous-address"}; + + const hit = configured[0]; + if (hit.kind.tag !== "Debit") return {ok: false, reason: "not-debit"}; + if (Object.values(currencies).some(config => config.escrowAccountId === hit.accountId)) return {ok: false, reason: "escrow-account"}; + const currency = Object.keys(currencies).find(id => currencies[id].ledger === ledgerName(hit.ledgerId))!; + return {ok: true, accountId: hit.accountId, address: hit.address, currency, username: hit.primaryUsername}; +} diff --git a/backend/brico-bot/src/stelo/links.ts b/backend/brico-bot/src/stelo/links.ts new file mode 100644 index 00000000..254863d6 --- /dev/null +++ b/backend/brico-bot/src/stelo/links.ts @@ -0,0 +1,161 @@ +/** + * links.ts — finalizes Stelo wallet links: resolves and verifies each submitted candidate address (or, + * for an unverified request, matches it in Stelo's public directory and waits for the browser to + * confirm), tells the module what happened, registers a link's ledger as its wallet currency and + * mirrors whether the bot can pull from each wallet and whether its linker still administers it. + */ +import type {Identity} from "spacetimedb"; +import type {EscrowCache} from "../app/escrow-cache.ts"; + +import type {SteloConfig} from "../config.ts"; +import type {Logger} from "../log.ts"; +import {createInFlight} from "./inflight.ts"; +import {canPull, type DirectoryAccount, matchDirectoryHits, ownsAccount, type UnverifiedMatch, verifySteloAddress} from "./link-verify.ts"; +import type {SteloReadModel} from "./stelo-cache.ts"; +import type {SteloWrites} from "./writes.ts"; + +export interface LinkDeps { + config: SteloConfig; + escrow: EscrowCache; + /** BitCraft player id -> brico account, from active `bitcraft-ea2` links. */ + playerAccounts(): ReadonlyMap; + stelo: SteloReadModel & { + readonly isLive: boolean; + searchAddress(address: string, ledgerId: bigint): Promise; + lookupAccount(accountId: bigint): Promise; + }; + writes: SteloWrites; + log: Logger; + /** How long a write blocks a repeat of itself after it settles; see `createInFlight`. */ + holdMs?: number; +} + +/** + * Safe to run every tick and whenever Stelo's rows change: a request that doesn't verify yet is + * retried as soon as the bot's view of Stelo moves (e.g. the user grants it membership after + * submitting), until the module expires the request. + */ +export function createLinkProcessor(deps: LinkDeps): {run(): void} { + const {config, escrow, stelo, writes, log} = deps; + const inFlight = createInFlight(log, deps.holdMs); + // The reason (and matched username) last sent per request, so an unchanged one isn't written again. + const lastReason = new Map(); + // Directory match per request code, for the address it was resolved for. + const matches = new Map(); + // Currency of a wallet the bot is not a member of, found by directory lookup, per link id. + const lookedUp = new Map(); + + function playersByAccount(): Map> { + const byAccount = new Map>(); + for (const [playerId, owner] of deps.playerAccounts()) { + const key = owner.toHexString(); + const ids = byAccount.get(key); + if (ids) ids.add(playerId); + else byAccount.set(key, new Set([playerId])); + } + return byAccount; + } + + const currencyOfLedger = (ledgerName: string | undefined) => + ledgerName === undefined ? undefined : Object.keys(config.currencies).find(id => config.currencies[id].ledger === ledgerName); + + /** Searches every configured ledger for the address and records the match for `code`. */ + function resolveInDirectory(code: string, address: string): void { + const key = `directory:${code}`; + if (inFlight.has(key) || !stelo.isLive) return; + const ledgerIds = Object.values(config.currencies) + .map(currency => stelo.ledgerByName(currency.ledger)?.id) + .filter((id): id is bigint => id !== undefined); + const search = Promise.all(ledgerIds.map(id => stelo.searchAddress(address, id))) + .then(found => { + matches.set(code, { + address, + result: matchDirectoryHits(address, found.flat(), config.currencies, id => stelo.ledger(id)?.name), + }); + }); + inFlight.track(key, search, "search stelo directory", {code}); + } + + function report(code: string, reason: string, username: string | undefined): void { + const key = `${reason}:${username ?? ""}`; + if (lastReason.get(code) === key) return; + lastReason.set(code, key); + inFlight.track(`link:${code}`, writes.noteLinkStatus(code, reason, username), "note stelo link status", {code}); + } + + return { + run() { + const players = playersByAccount(); + const playersOf = (account: Identity) => players.get(account.toHexString()) ?? new Set(); + + for (const request of escrow.state.linkRequests.values()) { + if (request.externalId !== undefined || request.candidateAddress === undefined || request.accountIdentity === undefined) continue; + const key = `link:${request.code}`; + if (inFlight.has(key)) continue; + + const address = request.candidateAddress; + const verdict = verifySteloAddress(address, playersOf(request.accountIdentity), stelo, config.currencies); + if (verdict.ok) { + // Verification can pass after an unverified match was shown; clear that state so the module accepts the finalize. + const finalize = (request.pendingReason === "confirm-unverified" + ? writes.noteLinkStatus(request.code, "verified", undefined) + : Promise.resolve() + ).then(() => writes.noteLinkExternal(request.code, verdict.accountId, address)); + inFlight.track(key, finalize, "finalize stelo link", {code: request.code}); + continue; + } + if (!request.unverified) { + report(request.code, verdict.reason, undefined); + continue; + } + + const known = matches.get(request.code); + if (!known || known.address !== address) { + resolveInDirectory(request.code, address); + continue; + } + const match = known.result; + if (!match.ok) report(request.code, match.reason, undefined); + else if (!request.confirmed) report(request.code, "confirm-unverified", match.username); + else inFlight.track(key, writes.noteLinkExternal(request.code, match.accountId, address), "finalize unverified stelo link", {code: request.code}); + } + for (const code of [...lastReason.keys()]) if (!escrow.state.linkRequests.has(code)) lastReason.delete(code); + for (const code of [...matches.keys()]) if (!escrow.state.linkRequests.has(code)) matches.delete(code); + + for (const link of escrow.state.steloLinks.values()) { + if (!/^\d+$/.test(link.externalId)) continue; + const accountId = BigInt(link.externalId); + + // Every active link needs its wallet row naming the ledger's currency. + if (!escrow.state.steloWallets.has(link.id)) { + const account = stelo.account(accountId); + const currency = account ? currencyOfLedger(account.ledgerName) : lookedUp.get(link.id); + const key = `wallet:${link.id}`; + if (!currency) { + // An unverified wallet is not in the bot's own accounts; its ledger comes from the directory. + const lookupKey = `lookup:${link.id}`; + if (!account && stelo.isLive && !inFlight.has(lookupKey)) { + inFlight.track(lookupKey, stelo.lookupAccount(accountId).then(hit => { + const found = hit && currencyOfLedger(stelo.ledger(hit.ledgerId)?.name); + if (found) lookedUp.set(link.id, found); + }), "look up stelo wallet", {link: link.id.toString()}); + } + continue; + } + if (inFlight.has(key)) continue; + inFlight.track(key, writes.registerWallet(link.id, currency), "register stelo wallet", {link: link.id.toString()}); + continue; + } + + // The status row mirrors the bot's live view, written on change only. Skipped while Stelo is down, when every role would read as missing. + const key = `wallet-status:${link.id}`; + if (!stelo.isLive || inFlight.has(key)) continue; + const pullable = canPull(stelo, accountId); + const verified = stelo.account(accountId) !== undefined && ownsAccount(stelo, accountId, playersOf(link.accountIdentity)); + const stored = escrow.state.walletStatuses.get(link.id); + if (stored?.canPull === pullable && stored.verified === verified) continue; + inFlight.track(key, writes.setWalletStatus(link.id, pullable, verified), "set stelo wallet status", {link: link.id.toString()}); + } + }, + }; +} diff --git a/backend/brico-bot/src/stelo/manager.ts b/backend/brico-bot/src/stelo/manager.ts new file mode 100644 index 00000000..6e5c6939 --- /dev/null +++ b/backend/brico-bot/src/stelo/manager.ts @@ -0,0 +1,176 @@ +/** + * manager.ts — the bot's Stelo step: one `step()` per tick that links wallets, executes pending + * transfer intents, refunds, reclassifies overlaps, sweeps payouts and reconciles the escrow wallet. + * Entitlement accounting never waits on this; with either connection down the step does nothing. + */ +import type {REST} from "@discordjs/rest"; +import type {Identity} from "spacetimedb"; +import type {BricoAppConnection} from "../app/connection.ts"; +import type {EscrowCache} from "../app/escrow-cache.ts"; +import type {SteloConfig} from "../config.ts"; +import type {Logger} from "../log.ts"; +import {steloEscrowDrift, steloUnknownTransfersTotal} from "../metrics.ts"; + +import {isPending} from "./books.ts"; +import type {SteloConnection} from "./connection.ts"; +import {createDirectTransfers, type DirectTransfers} from "./direct.ts"; +import {createTransferExecutor} from "./executor.ts"; +import {createFundingStatusSync, type FundingStatusSync} from "./funding-status.ts"; +import {createFundingGate, type FundingGate} from "./funding.ts"; +import {createInFlight} from "./inflight.ts"; +import {createLinkProcessor} from "./links.ts"; +import {createFundingNotifier} from "./notify.ts"; +import {createPayoutPlanner} from "./payouts.ts"; +import {findEscrowDrift, findUnknownEscrowTransfers} from "./reconcile.ts"; +import type {SteloCache} from "./stelo-cache.ts"; +import {createSteloWrites, type SteloWrites} from "./writes.ts"; + +export interface SteloManagerOptions { + config: SteloConfig; + app: BricoAppConnection; + stelo: SteloConnection; + escrow: EscrowCache; + steloCache: SteloCache; + playerAccounts(): ReadonlyMap; + /** A player's current name; used to say who unclaimed money was owed to. */ + playerName?(playerId: bigint): string | undefined; + /** Where funding alerts are posted besides the in-site toast; absent when the Discord bot is not configured. */ + discord?: {rest: REST; frontendOrigin: string}; + log: Logger; + writes?: SteloWrites; + now?: () => number; +} + +export interface SteloManager { + /** The funding gate the bounty engine consults before assigning an escrowed bounty. */ + readonly funding: FundingGate; + /** Keeps each escrowed-currency craft's funding-status row current. */ + readonly fundingStatus: FundingStatusSync; + /** Wallet-to-wallet transfers for `/pay stelo`; sent by this step's executor. */ + readonly direct: DirectTransfers; + /** + * Runs the step. `hasEnded(craftId)` is true for a craft prism still knows but that is no longer + * open (completed or removed); its escrow is closed so the unspent headroom is refunded. + */ + step(hasEnded: (craftId: bigint) => boolean): void; +} + +export function createSteloManager(options: SteloManagerOptions): SteloManager { + const {config, app, escrow, steloCache} = options; + const log = options.log.child("stelo"); + const now = options.now ?? Date.now; + const writes = options.writes ?? createSteloWrites(app, config.refPrefix); + const inFlight = createInFlight(log); + + const stelo = { + get isLive() { + return options.stelo.isLive; + }, + account: steloCache.account, + accountsByAddress: steloCache.accountsByAddress, + membersOf: steloCache.membersOf, + userByIdentity: steloCache.userByIdentity, + ledger: steloCache.ledger, + ledgerByName: steloCache.ledgerByName, + searchAddress: options.stelo.searchAddress, + searchAccounts: options.stelo.searchAccounts, + lookupAccount: options.stelo.lookupAccount, + transfers: steloCache.transfers, + findPostedTransfer: steloCache.findPostedTransfer, + balance: steloCache.balance, + createTransfer: options.stelo.createTransfer, + }; + + const {playerName} = options; + const notifier = createFundingNotifier({ + app, escrow, stelo, writes, discord: options.discord, log, + names: playerName && {player: playerName, playerAccounts: options.playerAccounts}, + }); + const funding = createFundingGate({config, escrow, stelo, playerAccounts: options.playerAccounts, writes, log, now}); + const fundingStatus = createFundingStatusSync({escrow, writes, notifier, log}); + const executor = createTransferExecutor({log, stelo, writes, now}); + const links = createLinkProcessor({config, escrow, playerAccounts: options.playerAccounts, stelo, writes, log}); + const payouts = createPayoutPlanner({config, escrow, playerAccounts: options.playerAccounts, stelo, writes, log, now, notifier}); + + const reportedUnknown = new Set(); + const drifting = new Set(); + let lastVersions = ""; + + function reconcile(): void { + const drifts = findEscrowDrift(config, escrow.state, steloCache); + const now = new Set(drifts.map(d => d.currency)); + for (const drift of drifts) { + if (!drifting.has(drift.currency)) { + log.error("escrow wallet balance differs from the books; pausing outgoing transfers", { + currency: drift.currency, expected: drift.expected.toString(), actual: drift.actual.toString(), + }); + } + steloEscrowDrift.set({currency: drift.currency}, 1); + } + for (const currency of drifting) { + if (!now.has(currency)) { + log.info("escrow wallet balance matches the books again; resuming transfers", {currency}); + steloEscrowDrift.set({currency}, 0); + } + } + drifting.clear(); + for (const currency of now) drifting.add(currency); + executor.paused = drifting.size > 0; + + for (const transfer of findUnknownEscrowTransfers(config, escrow.state, steloCache)) { + if (reportedUnknown.has(transfer.id)) continue; + reportedUnknown.add(transfer.id); + steloUnknownTransfersTotal.inc(); + // For an Asset transfer the receiver is the debited account. + const incoming = Object.values(config.currencies).some(c => c.escrowAccountId === transfer.debitAccountId); + log.warn(incoming + ? "transfer into an escrow wallet has no matching intent; it is not in the books and can be returned manually" + : "transfer out of an escrow wallet has no matching intent; it is not in the books and was not sent by this bot's current intents", { + transfer: transfer.id.toString(), debitAccountId: transfer.debitAccountId.toString(), + creditAccountId: transfer.creditAccountId.toString(), amount: transfer.postedAmount?.toString(), memo: transfer.memo, + }); + } + } + + /** Publishes each currency's unclaimed-money policy so the UI can state it; writes only what differs from the stored row. */ + function publishPolicies(): void { + for (const [currency, {unclaimed}] of Object.entries(config.currencies)) { + const rollTo = unclaimed.action === "rollover" ? unclaimed.to : undefined; + const stored = escrow.state.policies.get(currency); + if (stored?.action === unclaimed.action && stored.rollTo === rollTo && stored.afterDays === unclaimed.afterDays) continue; + if (inFlight.has(`policy:${currency}`)) continue; + inFlight.track(`policy:${currency}`, writes.setCurrencyPolicy(currency, unclaimed.action, rollTo, unclaimed.afterDays), "publish currency policy", {currency}); + } + } + + return { + funding, + fundingStatus, + direct: createDirectTransfers({config, escrow, playerAccounts: options.playerAccounts, stelo, writes}), + + step(hasEnded) { + if (!app.isLive) return; + for (const craftId of escrow.state.openEscrows.keys()) { + if (!hasEnded(craftId) || inFlight.has(`close:${craftId}`)) continue; + inFlight.track(`close:${craftId}`, writes.closeCraftEscrow(craftId), "close craft escrow", {craftId: craftId.toString()}); + } + + for (const craftId of escrow.state.fundingRows.keys()) if (hasEnded(craftId)) fundingStatus.clear(craftId); + fundingStatus.flush(); + + // Skip the (cheap but not free) passes when nothing moved and no time-based work can be due. + const versions = `${escrow.version}:${steloCache.version}`; + const changed = versions !== lastVersions; + lastVersions = versions; + + publishPolicies(); + links.run(); + if (changed && options.stelo.isLive) reconcile(); + executor.run([...escrow.state.transfers.values()].filter(isPending)); + payouts.refunds(); + if (changed) payouts.overlaps(); + payouts.sweep(); + payouts.resume(changed); + }, + }; +} diff --git a/backend/brico-bot/src/stelo/notify.ts b/backend/brico-bot/src/stelo/notify.ts new file mode 100644 index 00000000..882e22d0 --- /dev/null +++ b/backend/brico-bot/src/stelo/notify.ts @@ -0,0 +1,208 @@ +/** + * notify.ts — funding alerts: a toast notification row and a Discord message per sink, for the events + * a payer or payee can act on (a funding failure, a payout shortfall, unclaimed money moved). + */ +import type {DiscordNotifySink, FundingNotifyTrigger} from "@brico/bindings/brico-app/types"; +import {CURRENCY_LABELS} from "@brico/crafts/currencies"; +import { + DEFAULT_FUNDING_NOTIFY_TEMPLATE, + fundingEventOf, + type FundingNotificationKind, + type FundingNotifyEvent, + MY_BOUNTIES_PATH, + renderFundingTemplate, +} from "@brico/crafts/stelo"; +import {DiscordAPIError, type REST} from "@discordjs/rest"; +import {type RESTPostAPIChannelMessageJSONBody, Routes} from "discord-api-types/v10"; +import type {Identity} from "spacetimedb"; + +import type {BricoAppConnection} from "../app/connection.ts"; +import type {EscrowCache} from "../app/escrow-cache.ts"; +import type {Logger} from "../log.ts"; +import {notificationsTotal, timeReducerCall} from "../metrics.ts"; +import type {SteloReadModel} from "./stelo-cache.ts"; +import type {FundingAlert, SteloWrites} from "./writes.ts"; + +/** What one alert is about; fields an event doesn't have stay unset. */ +export interface FundingEvent { + kind: FundingNotificationKind; + role: "payer" | "payee"; + account: Identity; + currency: string; + craftId?: bigint; + /** Whole units. */ + amount?: bigint; + reason?: string; + /** The Stelo account of the wallet the event concerns, shown as its `#ADDRESS`. */ + walletAccountId?: bigint; + /** Unclaimed-money events: who owed the money and who it was owed to. */ + payer?: Identity; + payeePlayerId?: bigint; +} + +/** Kinds that report an ongoing problem; they post once per (account, currency, wallet) until the reason changes or `resolve` runs. */ +const STANDING_KINDS: readonly FundingNotificationKind[] = ["funding-failed", "payout-shortfall"]; + +export interface FundingNotifier { + /** Posts the alert, except a standing-problem kind whose reason is unchanged since it last posted. */ + notify(event: FundingEvent): void; + /** The wallet is working again: the next failure of `kind` posts even with the same reason. */ + resolve(kind: FundingNotificationKind, account: Identity, currency: string, walletAccountId: bigint | undefined): void; +} + +export interface FundingNotifierDeps { + app: BricoAppConnection; + escrow: EscrowCache; + stelo: SteloReadModel; + writes: SteloWrites; + /** Absent when the Discord bot is not configured; only the toast sink is served then. */ + discord?: {rest: REST; frontendOrigin: string}; + /** Resolves party names; without it alerts carry none. */ + names?: { + player(playerId: bigint): string | undefined; + playerAccounts(): ReadonlyMap; + }; + log: Logger; +} + +const describe = (cause: unknown) => (cause instanceof Error ? cause.message : String(cause)); +const isGone = (cause: unknown) => cause instanceof DiscordAPIError && (cause.status === 403 || cause.status === 404); + +export function createFundingNotifier(deps: FundingNotifierDeps): FundingNotifier { + const {app, escrow, stelo, writes, discord} = deps; + const log = deps.log.child("funding-notify"); + // The reason each standing problem last posted with. In memory: a restart can post an unresolved problem once more. + const standing = new Map(); + const standingKey = (kind: FundingNotificationKind, account: Identity, currency: string, wallet: bigint | undefined) => + `${kind}:${account.toHexString()}:${currency}:${wallet ?? ""}`; + + function triggersOf(account: Identity): FundingNotifyTrigger[] { + return [...escrow.state.fundingTriggers.values()].filter(trigger => trigger.accountIdentity.isEqual(account)); + } + + /** With no toast trigger row the toast sink is on for every event. */ + function toastEnabled(triggers: FundingNotifyTrigger[], event: FundingNotifyEvent): boolean { + const explicit = triggers.find(trigger => trigger.sink.tag === "Toast"); + return explicit ? explicit[event] : true; + } + + /** The account's display name, else the name of its first linked player. */ + function accountName(account: Identity): string | undefined { + const conn = app.connection?.connection; + if (conn?.isActive) { + const acc = conn.db.allAccount.identity.find(account); + if (acc && acc.displayName) return acc.displayName; + } + if (!deps.names) return undefined; + for (const [playerId, owner] of deps.names.playerAccounts()) { + const name = owner.isEqual(account) ? deps.names.player(playerId) : undefined; + if (name) return name; + } + return undefined; + } + + function partiesOf(event: FundingEvent): {payerName?: string; payeeName?: string} { + return { + payerName: event.payer ? accountName(event.payer) : undefined, + payeeName: event.payeePlayerId === undefined ? undefined : deps.names?.player(event.payeePlayerId), + }; + } + + function toast(event: FundingEvent): void { + const payload: FundingAlert = { + kind: event.kind, role: event.role, currency: event.currency, + craftId: event.craftId, amount: event.amount, reason: event.reason, + payerName: undefined, payeeName: undefined, ...partiesOf(event), + }; + writes.postFundingNotification(event.account, payload) + .then(() => notificationsTotal.inc({sink: "toast", kind: event.kind, outcome: "ok"})) + .catch(cause => { + notificationsTotal.inc({sink: "toast", kind: event.kind, outcome: "failed"}); + log.error("post_funding_notification failed", {kind: event.kind, error: describe(cause)}); + }); + } + + function sinkOf(id: string, account: Identity): DiscordNotifySink | undefined { + const conn = app.connection?.connection; + if (!conn?.isActive) return undefined; + for (const sink of conn.db.allDiscordNotifySink.iter()) { + if (sink.id === id && sink.deletedAt === undefined && sink.accountIdentity.isEqual(account)) return sink; + } + return undefined; + } + + async function sendDiscord(event: FundingEvent, trigger: FundingNotifyTrigger): Promise { + if (!discord || trigger.sink.tag !== "Discord") return; + const sink = sinkOf(trigger.sink.value.sinkId, event.account); + if (!sink) return; + const target = [...escrow.state.fundingTargets.values()] + .find(row => row.sinkId === sink.id && row.accountIdentity.isEqual(event.account)); + const key = fundingEventOf(event.kind); + const stored = key === "fundingFailed" ? target?.fundingFailedTemplate + : key === "payoutShortfall" ? target?.payoutShortfallTemplate : target?.unclaimedTemplate; + + const mentionType = target?.mentionType ?? sink.defaultMentionType; + const mentionId = target?.mentionId ?? sink.defaultMentionId; + const mentionText = mentionType && mentionId ? (mentionType === "user" ? `<@${mentionId}>` : `<@&${mentionId}>`) : null; + const wallet = event.walletAccountId === undefined ? null : `#${stelo.account(event.walletAccountId)?.address ?? event.walletAccountId}`; + const reason = event.kind === "unclaimed-rolled-over" ? "rolled-over" : event.kind === "unclaimed-returned" ? "returned" : (event.reason ?? null); + const parties = partiesOf(event); + const content = renderFundingTemplate(stored ?? DEFAULT_FUNDING_NOTIFY_TEMPLATE[key], { + payer: parties.payerName ?? null, + payee: parties.payeeName ?? null, + currency: CURRENCY_LABELS[event.currency] ?? event.currency, + amount: event.amount === undefined ? null : event.amount.toString(), + craft: event.craftId === undefined ? null : `[Craft #${event.craftId}](<${discord.frontendOrigin}/tools/crafts/${event.craftId}>)`, + bounties: `[My bounties](<${discord.frontendOrigin}${MY_BOUNTIES_PATH}>)`, + wallet, + reason, + mentionText, + }); + + const body = { + content, + allowed_mentions: { + parse: [], + users: mentionType === "user" && mentionId ? [mentionId] : [], + roles: mentionType === "role" && mentionId ? [mentionId] : [], + }, + } satisfies RESTPostAPIChannelMessageJSONBody; + + try { + await discord.rest.post(Routes.channelMessages(sink.channelId), {body}); + notificationsTotal.inc({sink: "discord", kind: event.kind, outcome: "ok"}); + } catch (cause) { + notificationsTotal.inc({sink: "discord", kind: event.kind, outcome: isGone(cause) ? "detached" : "failed"}); + if (!isGone(cause)) { + log.error("failed to post funding message", {sink: sink.id, error: describe(cause)}); + return; + } + log.warn("funding sink channel no longer accessible; removing sink", {sink: sink.id, channel: sink.channelId}); + const conn = app.connection?.connection; + if (conn?.isActive) { + await timeReducerCall("detach_discord_notify_sink", conn.reducers.detachDiscordNotifySink({id: sink.id})) + .catch(detachCause => log.error("detach_discord_notify_sink failed", {sink: sink.id, error: describe(detachCause)})); + } + } + } + + return { + resolve(kind, account, currency, walletAccountId) { + standing.delete(standingKey(kind, account, currency, walletAccountId)); + }, + notify(event) { + if (STANDING_KINDS.includes(event.kind)) { + const id = standingKey(event.kind, event.account, event.currency, event.walletAccountId); + if (standing.get(id) === (event.reason ?? "")) return; + standing.set(id, event.reason ?? ""); + } + const key = fundingEventOf(event.kind); + const triggers = triggersOf(event.account); + if (toastEnabled(triggers, key)) toast(event); + for (const trigger of triggers) { + if (trigger.sink.tag !== "Discord" || !trigger[key]) continue; + sendDiscord(event, trigger).catch(cause => log.error("funding notification failed", {error: describe(cause)})); + } + }, + }; +} diff --git a/backend/brico-bot/src/stelo/payouts.ts b/backend/brico-bot/src/stelo/payouts.ts new file mode 100644 index 00000000..e4a9539e --- /dev/null +++ b/backend/brico-bot/src/stelo/payouts.ts @@ -0,0 +1,259 @@ +/** + * payouts.ts — the money that leaves escrow: refunds of released headroom, reclassification of + * overlapping payments, the interval payout sweep, and closing out money owed to payees who can't be + * paid. Each step writes transfer intents or module reducers; the executor moves the money. + */ +import type {BountyEscrowPool, SteloTransfer} from "@brico/bindings/brico-app/types"; +import {overlapRelease, splitPayout} from "@brico/crafts/entitlement"; +import {Identity, Timestamp} from "spacetimedb"; +import {type EscrowCache, poolKey, receivingKey} from "../app/escrow-cache.ts"; + +import type {SteloConfig} from "../config.ts"; +import type {Logger} from "../log.ts"; +import {isPending, reservableRefundDue, timestampMs, toSteloAmount} from "./books.ts"; +import {createInFlight} from "./inflight.ts"; +import {canFundFrom} from "./link-verify.ts"; +import type {FundingNotifier} from "./notify.ts"; +import type {SteloReadModel} from "./stelo-cache.ts"; +import type {SteloWrites} from "./writes.ts"; + +const DAY_MS = 24 * 60 * 60 * 1000; + +export interface PayoutDeps { + config: SteloConfig; + escrow: EscrowCache; + /** BitCraft player id -> brico account, from active `bitcraft-ea2` links. */ + playerAccounts(): ReadonlyMap; + stelo: SteloReadModel & {readonly isLive: boolean}; + writes: SteloWrites; + log: Logger; + now(): number; + /** Raises payout-shortfall and unclaimed alerts; absent in tests. */ + notifier?: FundingNotifier; +} + +export interface PayoutPlanner { + /** Sends released headroom back to the wallets that funded it. */ + refunds(): void; + /** Moves directly-paid units that have since become guaranteed over to escrow-paid. */ + overlaps(): void; + /** + * Once per interval, pulls each payee's loyalty extras from the paying wallet into escrow and pays + * the guaranteed part plus the pulled extras in one transfer; closes out what can't be paid. + */ + sweep(): void; + /** Sends the payout of pools whose extras pull has finished. `changed` is whether any books moved since the last call. */ + resume(changed: boolean): void; +} + +export function createPayoutPlanner(deps: PayoutDeps): PayoutPlanner { + const {config, escrow, stelo, writes, log} = deps; + const inFlight = createInFlight(log); + + /** The Stelo account that receives `payee`'s payouts in `currency`, or null if they have no routable wallet. */ + function route(payee: bigint, currency: string): bigint | null { + const account = deps.playerAccounts().get(payee); + if (!account) return null; + const receiving = escrow.state.receivingWallets.get(receivingKey(account, currency)); + const link = receiving && escrow.state.steloLinks.get(receiving.linkId); + return link ? BigInt(link.externalId) : null; + } + + const poolId = (pool: BountyEscrowPool) => poolKey(pool.payerAccountIdentity, pool.payeePlayerId, pool.currency, pool.fundingAccountId); + + /** The pool's transfers of one purpose, oldest first. */ + function transfersOf(pool: BountyEscrowPool, tag: "Payout" | "Extras"): SteloTransfer[] { + const found: SteloTransfer[] = []; + for (const transfer of escrow.state.transfers.values()) { + if (transfer.purpose.tag !== tag || !transfer.payerAccountIdentity.isEqual(pool.payerAccountIdentity)) continue; + const {payeePlayerId, fundingAccountId} = transfer.purpose.value; + if (payeePlayerId === pool.payeePlayerId && fundingAccountId === pool.fundingAccountId) found.push(transfer); + } + return found.sort((a, b) => (a.id < b.id ? -1 : 1)); + } + + const hasPendingPayout = (pool: BountyEscrowPool, tag: "Payout" | "Extras") => transfersOf(pool, tag).some(isPending); + + // Pools whose extras pull was sent this sweep, waiting for it to settle or fail before their one payout goes out. + // `afterId` is the newest earlier pull, so the pull of this sweep is the one with a higher id. + const awaiting = new Map(); + const AWAIT_ECHO_MS = 60_000; + + /** The notifier posts one shortfall alert per payer, currency and wallet until the reason changes or an extras pull settles. */ + function alertShortfall(pool: BountyEscrowPool, amount: bigint, reason: "cannot-pull" | "not-authorized" | "insufficient-funds"): void { + deps.notifier?.notify({ + kind: "payout-shortfall", role: "payer", account: pool.payerAccountIdentity, currency: pool.currency, + amount, reason, walletAccountId: pool.fundingAccountId, + }); + } + + /** Sends the pool's whole payout in one transfer from escrow: the guaranteed part plus whatever extras were pulled. */ + function sendPayout(pool: BountyEscrowPool, destination: bigint): void { + const currencyConfig = config.currencies[pool.currency]; + const scale = stelo.account(currencyConfig.escrowAccountId)?.ledgerScale; + const base = poolId(pool); + if (scale === undefined || hasPendingPayout(pool, "Payout") || inFlight.has(`payout:${base}`)) return; + const {fromEscrow, fromWallet} = splitPayout(pool); + const extras = pool.pulledExtras < fromWallet ? pool.pulledExtras : fromWallet; + const amount = fromEscrow + extras; + if (amount <= 0n) return; + inFlight.track(`payout:${base}`, writes.recordIntent({ + currency: pool.currency, + payer: pool.payerAccountIdentity, + fromAccountId: currencyConfig.escrowAccountId, + toAccountId: destination, + amount, + steloAmount: toSteloAmount(amount, scale), + purpose: {tag: "Payout", value: {payeePlayerId: pool.payeePlayerId, fundingAccountId: pool.fundingAccountId, extras}}, + }), "payout intent", {payee: pool.payeePlayerId.toString(), currency: pool.currency}); + } + + /** + * Tries to pull the pool's still-unpulled loyalty extras from its paying wallet into escrow. + * Returns true when a pull is in flight (so the payout waits for it). A pull that cannot be tried + * raises a shortfall alert and leaves the extras owed; the guaranteed part is paid regardless. + */ + function pullExtras(pool: BountyEscrowPool, need: bigint): boolean { + const base = poolId(pool); + if (hasPendingPayout(pool, "Extras") || inFlight.has(`extras:${base}`)) return true; + const currencyConfig = config.currencies[pool.currency]; + const scale = stelo.account(currencyConfig.escrowAccountId)?.ledgerScale; + if (scale === undefined) return false; + + const raw = toSteloAmount(need, scale); + const payerPlayers = new Set(); + for (const [playerId, owner] of deps.playerAccounts()) if (owner.isEqual(pool.payerAccountIdentity)) payerPlayers.add(playerId); + const allowed = canFundFrom(stelo, pool.fundingAccountId, payerPlayers); + if (allowed !== "ok" || (stelo.balance(pool.fundingAccountId) ?? 0n) < raw) { + alertShortfall(pool, need, allowed !== "ok" ? allowed : "insufficient-funds"); + return false; + } + inFlight.track(`extras:${base}`, writes.recordIntent({ + currency: pool.currency, + payer: pool.payerAccountIdentity, + fromAccountId: pool.fundingAccountId, + toAccountId: currencyConfig.escrowAccountId, + amount: need, + steloAmount: raw, + purpose: {tag: "Extras", value: {payeePlayerId: pool.payeePlayerId, fundingAccountId: pool.fundingAccountId}}, + }), "extras pull intent", {payee: pool.payeePlayerId.toString(), currency: pool.currency}); + const earlier = transfersOf(pool, "Extras"); + awaiting.set(base, {since: deps.now(), afterId: earlier.length > 0 ? earlier[earlier.length - 1].id : 0n}); + return true; + } + + function payoutPool(pool: BountyEscrowPool, destination: bigint): void { + const {fromWallet} = splitPayout(pool); + const need = fromWallet - pool.pulledExtras; + if (need > 0n && pullExtras(pool, need)) return; + sendPayout(pool, destination); + } + + function closeOutUnclaimed(pool: BountyEscrowPool): void { + const policy = config.currencies[pool.currency].unclaimed; + const key = `unclaimed:${pool.payerAccountIdentity.toHexString()}:${pool.payeePlayerId}:${pool.currency}:${pool.fundingAccountId}`; + if (inFlight.has(key)) return; + const fields = {payee: pool.payeePlayerId.toString(), currency: pool.currency}; + if (pool.unclaimedSince === undefined) { + inFlight.track(key, writes.syncUnclaimed(pool.payerAccountIdentity, pool.payeePlayerId, pool.currency, pool.fundingAccountId), "flag unclaimed payee", fields); + return; + } + if (deps.now() - timestampMs(pool.unclaimedSince) < policy.afterDays * DAY_MS) return; + if (hasPendingPayout(pool, "Payout") || hasPendingPayout(pool, "Extras")) return; + const owed = splitPayout(pool).owed; + const settled = writes.settleUnclaimed( + pool.payerAccountIdentity, pool.payeePlayerId, pool.currency, pool.fundingAccountId, + policy.action === "rollover" ? policy.to : undefined, + ); + inFlight.track(key, settled, "settle unclaimed payee", fields); + settled.then(() => { + const kind = policy.action === "rollover" ? "unclaimed-rolled-over" : "unclaimed-returned"; + const base = { + kind, currency: pool.currency, amount: owed, walletAccountId: pool.fundingAccountId, + payer: pool.payerAccountIdentity, payeePlayerId: pool.payeePlayerId, + } as const; + deps.notifier?.notify({...base, role: "payer", account: pool.payerAccountIdentity}); + const payee = deps.playerAccounts().get(pool.payeePlayerId); + if (payee) deps.notifier?.notify({...base, role: "payee", account: payee}); + }).catch(() => {}); + } + + return { + refunds() { + if (!stelo.isLive) return; + for (const wallet of escrow.state.escrowWallets.values()) { + const currencyConfig = config.currencies[wallet.currency]; + if (!currencyConfig) continue; + const amount = reservableRefundDue(escrow.state, wallet.payerAccountIdentity, wallet.currency, wallet.fundingAccountId); + const scale = stelo.account(currencyConfig.escrowAccountId)?.ledgerScale; + const key = `refund:${wallet.payerAccountIdentity.toHexString()}:${wallet.currency}:${wallet.fundingAccountId}`; + if (amount <= 0n || scale === undefined || inFlight.has(key)) continue; + inFlight.track(key, writes.recordIntent({ + currency: wallet.currency, + payer: wallet.payerAccountIdentity, + fromAccountId: currencyConfig.escrowAccountId, + toAccountId: wallet.fundingAccountId, + amount, + steloAmount: toSteloAmount(amount, scale), + purpose: {tag: "Refund", value: {fundingAccountId: wallet.fundingAccountId}}, + }), "refund intent", {currency: wallet.currency, account: wallet.fundingAccountId.toString()}); + } + }, + + overlaps() { + for (const pool of escrow.state.pools.values()) { + if (overlapRelease(pool) <= 0n) continue; + const key = `overlap:${pool.payerAccountIdentity.toHexString()}:${pool.payeePlayerId}:${pool.currency}`; + if (inFlight.has(key)) continue; + inFlight.track(key, writes.releaseOverlap(pool.payerAccountIdentity, pool.payeePlayerId, pool.currency), "release escrow overlap", {payee: pool.payeePlayerId.toString()}); + } + }, + + sweep() { + if (!stelo.isLive || inFlight.has("sweep")) return; + const last = escrow.state.schedule ? timestampMs(escrow.state.schedule.lastSweepAt) : 0; + if (deps.now() - last < config.payoutIntervalMs) return; + + const min = BigInt(config.minPayout); + for (const pool of escrow.state.pools.values()) { + if (!config.currencies[pool.currency]) continue; + const {owed} = splitPayout(pool); + if (owed <= 0n || owed < min) continue; + const destination = route(pool.payeePlayerId, pool.currency); + if (destination === null) closeOutUnclaimed(pool); + else payoutPool(pool, destination); + } + inFlight.track("sweep", writes.setPayoutSchedule(BigInt(config.payoutIntervalMs) * 1000n, Timestamp.now()), "set payout schedule"); + }, + + resume(changed) { + if (!stelo.isLive) return; + // Pools whose extras pull has since settled or failed: send the one payout. + for (const [id, entry] of [...awaiting]) { + const pool = escrow.state.pools.get(id); + if (!pool) { + awaiting.delete(id); + continue; + } + const pulls = transfersOf(pool, "Extras").filter(transfer => transfer.id > entry.afterId); + const latest = pulls[pulls.length - 1]; + if (latest ? isPending(latest) : deps.now() - entry.since < AWAIT_ECHO_MS) continue; + awaiting.delete(id); + if (latest?.state.tag === "Failed") { + alertShortfall(pool, latest.amount, latest.failureReason === "no-permission" ? "cannot-pull" : "insufficient-funds"); + } else if (latest?.state.tag === "Settled") { + deps.notifier?.resolve("payout-shortfall", pool.payerAccountIdentity, pool.currency, pool.fundingAccountId); + } + const destination = route(pool.payeePlayerId, pool.currency); + if (destination !== null) sendPayout(pool, destination); + } + // Extras already in escrow with no payout on the way (e.g. after a restart) are paid out. + if (!changed) return; + for (const pool of escrow.state.pools.values()) { + if (pool.pulledExtras <= 0n || awaiting.has(poolId(pool)) || hasPendingPayout(pool, "Extras")) continue; + const destination = route(pool.payeePlayerId, pool.currency); + if (destination !== null) sendPayout(pool, destination); + } + }, + }; +} diff --git a/backend/brico-bot/src/stelo/reconcile.ts b/backend/brico-bot/src/stelo/reconcile.ts new file mode 100644 index 00000000..0a8a24d6 --- /dev/null +++ b/backend/brico-bot/src/stelo/reconcile.ts @@ -0,0 +1,69 @@ +/** + * reconcile.ts — checks the escrow wallet's live Stelo balance against the books, and flags + * transfers on it that no intent explains. + */ +import type {EscrowState} from "../app/escrow-cache.ts"; +import type {SteloConfig} from "../config.ts"; +import type {SteloReadModel, SteloTransfer} from "./stelo-cache.ts"; + +export interface EscrowDrift { + currency: string; + /** Raw Stelo units the books say the escrow wallet should hold, counting transfers already posted but not yet settled. */ + expected: bigint; + actual: bigint; +} + +/** + * Currencies whose escrow wallet balance differs from `Σ journal × 10^scale` plus the signed raw + * amount of intents whose transfer is posted but not settled yet (money moved, books not updated) + * and the net of posted transfers no intent explains (e.g. a stray deposit, which the books never + * hold and which must not pause the bot). Currencies whose wallet isn't visible yet are skipped. + */ +export function findEscrowDrift(config: SteloConfig, escrow: EscrowState, stelo: SteloReadModel): EscrowDrift[] { + const drifts: EscrowDrift[] = []; + const unexplained = findUnknownEscrowTransfers(config, escrow, stelo); + for (const [currency, {escrowAccountId}] of Object.entries(config.currencies)) { + const account = stelo.account(escrowAccountId); + if (!account) continue; + + let expected = (escrow.journalBalance.get(currency) ?? 0n) * 10n ** BigInt(account.ledgerScale); + for (const intent of escrow.transfers.values()) { + if (intent.state.tag !== "Pending" || intent.currency !== currency) continue; + const posted = stelo.findPostedTransfer({ + ref: intent.ref, fromAccountId: intent.fromAccountId, toAccountId: intent.toAccountId, steloAmount: intent.steloAmount, + }); + if (!posted) continue; + if (intent.toAccountId === escrowAccountId) expected += intent.steloAmount; + else if (intent.fromAccountId === escrowAccountId) expected -= intent.steloAmount; + } + for (const transfer of unexplained) { + if (transfer.state.tag !== "Posted" || transfer.postedAmount === undefined) continue; + // For an Asset transfer the receiver is the debited account and the sender the credited one. + if (transfer.debitAccountId === escrowAccountId) expected += transfer.postedAmount; + if (transfer.creditAccountId === escrowAccountId) expected -= transfer.postedAmount; + } + if (expected !== account.balance) drifts.push({currency, expected, actual: account.balance}); + } + return drifts; +} + +/** + * Transfers touching an escrow wallet that no intent explains. A transfer is explained only when + * its memo is an intent's ref *and* its accounts and posted amount match that intent, so a + * stranger's transfer can't hide behind a copied memo. + */ +export function findUnknownEscrowTransfers(config: SteloConfig, escrow: EscrowState, stelo: SteloReadModel): SteloTransfer[] { + const escrowAccounts = new Set(Object.values(config.currencies).map(c => c.escrowAccountId)); + const intents = new Map(); + for (const intent of escrow.transfers.values()) intents.set(intent.ref, intent); + const unknown: SteloTransfer[] = []; + for (const transfer of stelo.transfers()) { + if (!escrowAccounts.has(transfer.debitAccountId) && !escrowAccounts.has(transfer.creditAccountId)) continue; + const intent = transfer.memo === undefined ? undefined : intents.get(transfer.memo); + const explained = intent !== undefined && transfer.creditAccountId === intent.fromAccountId + && transfer.debitAccountId === intent.toAccountId + && (transfer.state.tag !== "Posted" || transfer.postedAmount === intent.steloAmount); + if (!explained) unknown.push(transfer); + } + return unknown; +} diff --git a/backend/brico-bot/src/stelo/stelo-cache.ts b/backend/brico-bot/src/stelo/stelo-cache.ts new file mode 100644 index 00000000..df5fd0d9 --- /dev/null +++ b/backend/brico-bot/src/stelo/stelo-cache.ts @@ -0,0 +1,199 @@ +/** + * stelo-cache.ts — the slice of Stelo's tables the bot reads, mirrored from row callbacks. + * + * Rows are typed structurally (what the bot reads, not the generated classes) so the logic that + * consumes `SteloReadModel` needs no SDK in tests. + */ +import type {DbConnection} from "@brico/bindings/stelo"; + +import type {TableFeed} from "../spacetime/feed.ts"; + +type Tagged = {tag: string}; +type Hex = {toHexString(): string}; + +/** An account the bot is a member of (`my_accounts`). `role` is the bot's own role on it. */ +export interface SteloAccount { + accountId: bigint; + address: string; + kind: Tagged; + balance: bigint; + ledgerId: bigint; + ledgerName: string; + ledgerScale: number; + role: Tagged; +} + +/** A member of one of those accounts (`my_accounts_members`). */ +export interface SteloMember { + id: bigint; + accountId: bigint; + memberId: Hex; + kind: Tagged; + role: Tagged; +} + +/** A transfer on one of those accounts (`my_transfers`). */ +export interface SteloTransfer { + id: bigint; + debitAccountId: bigint; + creditAccountId: bigint; + postedAmount: bigint | undefined; + state: Tagged; + memo: string | undefined; +} + +export interface SteloUser { + id: Hex; + bitcraftPlayerId: bigint; +} + +export interface SteloLedger { + id: bigint; + name: string; + scale: number; +} + +const ROLE_RANK: Record = {Read: 1, Write: 2, Admin: 3, Owner: 4}; + +/** True if `role` is at least `min`. */ +export function roleAtLeast(role: Tagged | undefined, min: "Read" | "Write" | "Admin" | "Owner"): boolean { + return role !== undefined && (ROLE_RANK[role.tag] ?? 0) >= ROLE_RANK[min]; +} + +export interface SteloReadModel { + account(accountId: bigint): SteloAccount | undefined; + /** Every account the bot can see with this address (addresses are unique per ledger, not globally). Case-insensitive. */ + accountsByAddress(address: string): readonly SteloAccount[]; + membersOf(accountId: bigint): readonly SteloMember[]; + userByIdentity(identityHex: string): SteloUser | undefined; + ledger(ledgerId: bigint): SteloLedger | undefined; + ledgerByName(name: string): SteloLedger | undefined; + /** Every transfer seen so far, deduplicated by id. */ + transfers(): Iterable; + /** The posted Asset transfer for a ref between exactly these accounts for exactly this raw amount, if one is visible. */ + findPostedTransfer(match: {ref: string; fromAccountId: bigint; toAccountId: bigint; steloAmount: bigint}): SteloTransfer | undefined; + /** Live balance of an account (raw units), if the bot can see it. */ + balance(accountId: bigint): bigint | undefined; +} + +export interface SteloCache extends SteloReadModel { + readonly accounts: TableFeed; + readonly members: TableFeed; + readonly transferRows: TableFeed; + readonly users: TableFeed; + readonly ledgers: TableFeed; + /** Replaces everything with a full read of `conn`'s tables. */ + load(conn: DbConnection): void; + /** Incremented on every change, so a consumer can tell whether anything moved since it last looked. */ + readonly version: number; +} + +export function createSteloCache(): SteloCache { + const accountMap = new Map(); + const membersByAccount = new Map>(); + const userMap = new Map(); + const ledgerMap = new Map(); + // `my_transfers` has no primary key and can deliver a transfer twice (once per visible leg), so this is insert-only keyed by id. + const transferMap = new Map(); + const transfersByRef = new Map>(); + let version = 0; + + const bump = () => void (version += 1); + + function setMember(row: SteloMember): void { + let byId = membersByAccount.get(row.accountId); + if (!byId) membersByAccount.set(row.accountId, (byId = new Map())); + byId.set(row.id, row); + bump(); + } + + function removeMember(row: SteloMember): void { + const byId = membersByAccount.get(row.accountId); + byId?.delete(row.id); + if (byId?.size === 0) membersByAccount.delete(row.accountId); + bump(); + } + + function setTransfer(row: SteloTransfer): void { + const known = transferMap.get(row.id); + transferMap.set(row.id, row); + if (row.memo !== undefined) { + const ids = transfersByRef.get(row.memo); + if (ids) ids.add(row.id); + else transfersByRef.set(row.memo, new Set([row.id])); + } + if (!known || known.state.tag !== row.state.tag) bump(); + } + + const identityKey = (row: SteloUser) => row.id.toHexString(); + + // noinspection CommaExpressionJS + return { + accounts: { + insert: row => (accountMap.set(row.accountId, row), bump()), + update: (before, after) => { + if (before.accountId !== after.accountId) accountMap.delete(before.accountId); + accountMap.set(after.accountId, after); + bump(); + }, + delete: row => (accountMap.delete(row.accountId), bump()), + }, + members: {insert: setMember, update: (before, after) => (removeMember(before), setMember(after)), delete: removeMember}, + // A transfer is never removed from what the bot has already seen. + transferRows: {insert: setTransfer, update: (_before, after) => setTransfer(after), delete: () => {}}, + users: { + insert: row => (userMap.set(identityKey(row), row), bump()), + update: (before, after) => { + if (identityKey(before) !== identityKey(after)) userMap.delete(identityKey(before)); + userMap.set(identityKey(after), after); + bump(); + }, + delete: row => (userMap.delete(identityKey(row)), bump()), + }, + ledgers: { + insert: row => (ledgerMap.set(row.id, row), bump()), + update: (_before, after) => (ledgerMap.set(after.id, after), bump()), + delete: row => (ledgerMap.delete(row.id), bump()), + }, + + load(conn) { + accountMap.clear(); + membersByAccount.clear(); + userMap.clear(); + ledgerMap.clear(); + for (const row of conn.db.myAccounts.iter()) accountMap.set(row.accountId, row); + for (const row of conn.db.myAccountsMembers.iter()) setMember(row); + for (const row of conn.db.user.iter()) userMap.set(identityKey(row), row); + for (const row of conn.db.ledger.iter()) ledgerMap.set(row.id, row); + for (const row of conn.db.myTransfers.iter()) setTransfer(row); + bump(); + }, + + get version() { + return version; + }, + + account: id => accountMap.get(id), + accountsByAddress(address) { + const wanted = address.toUpperCase(); + return [...accountMap.values()].filter(account => account.address.toUpperCase() === wanted); + }, + membersOf: id => [...(membersByAccount.get(id)?.values() ?? [])], + userByIdentity: hex => userMap.get(hex), + ledger: id => ledgerMap.get(id), + ledgerByName: name => [...ledgerMap.values()].find(ledger => ledger.name === name), + transfers: () => transferMap.values(), + balance: id => accountMap.get(id)?.balance, + findPostedTransfer({ref, fromAccountId, toAccountId, steloAmount}) { + for (const id of transfersByRef.get(ref) ?? []) { + const row = transferMap.get(id); + // For an Asset transfer the sender is the credited account and the receiver the debited one. + if (row && row.state.tag === "Posted" && row.creditAccountId === fromAccountId + && row.debitAccountId === toAccountId && row.postedAmount === steloAmount) { + return row; + } + } + return undefined; + }, + }; +} diff --git a/backend/brico-bot/src/stelo/stelo.test.ts b/backend/brico-bot/src/stelo/stelo.test.ts new file mode 100644 index 00000000..c767a105 --- /dev/null +++ b/backend/brico-bot/src/stelo/stelo.test.ts @@ -0,0 +1,727 @@ +import type { + BountyEscrowPool, + BountyEscrowWallet, + CraftBountyEscrow, + IntegrationLinkRequest, + LinkedIntegration, + SteloReceivingWallet, + SteloTransfer, + SteloTransferPurpose, + SteloWallet, +} from "@brico/bindings/brico-app/types"; +import assert from "node:assert/strict"; +import {describe, it} from "node:test"; +import {Identity, Timestamp} from "spacetimedb"; + +import {createEscrowCache} from "../app/escrow-cache.ts"; +import type {SteloConfig} from "../config.ts"; +import type {Logger} from "../log.ts"; +import {createDirectTransfers, walletLabel} from "./direct.ts"; +import {classifyTransferError, createTransferExecutor} from "./executor.ts"; +import {createFundingStatusSync} from "./funding-status.ts"; +import {createFundingGate, type FundingRequest} from "./funding.ts"; +import {createLinkProcessor} from "./links.ts"; +import {createFundingNotifier} from "./notify.ts"; +import {createPayoutPlanner} from "./payouts.ts"; +import {findEscrowDrift, findUnknownEscrowTransfers} from "./reconcile.ts"; +import {createSteloCache} from "./stelo-cache.ts"; +import type {FundingRow, SteloWrites, TransferIntentArgs} from "./writes.ts"; + +const quiet: Logger = {debug() {}, info() {}, warn() {}, error() {}, child: () => quiet}; +const PAYER = new Identity("01".padStart(64, "0")); +const PAYEE_ACCOUNT = new Identity("02".padStart(64, "0")); +const ESCROW = 900n; +const WALLET = 101n; +const RECEIVING = 202n; +const PAYER_PLAYER = 7001n; +const NOW = 1_700_000_000_000; + +const config: SteloConfig = { + target: {label: "stelo", uri: "http://x", database: "stelofinance"}, + currencies: {"stelo-hex-coin": {ledger: "Hex Coin", escrowAccountId: ESCROW, unclaimed: {action: "rollover", to: "hex-coin", afterDays: 7}}}, + refPrefix: "bx", + payoutIntervalMs: 86_400_000, + minPayout: 1, + retryBaseMs: 1000, + retryMaxMs: 60_000, + auth: {mode: "permanent"}, + authAlertWebhook: null, +}; + +const ts = (ms: number) => new Timestamp(BigInt(ms) * 1000n); + +/** Records every write instead of performing it. */ +function fakeWrites() { + const calls: string[] = []; + const intents: TransferIntentArgs[] = []; + const fundingRows: FundingRow[] = []; + const ok = (what: string) => async () => void calls.push(what); + const writes: SteloWrites = { + recordIntent: async args => void (intents.push(args), calls.push(`intent ${args.purpose.tag}`)), + settleTransfer: async (id, steloId) => void calls.push(`settle ${id} ${steloId}`), + failTransfer: async (id, reason) => void calls.push(`fail ${id} ${reason}`), + raiseFromRefundDue: async craftId => void calls.push(`raise ${craftId}`), + closeCraftEscrow: async craftId => void calls.push(`close ${craftId}`), + releaseOverlap: ok("overlap"), + syncUnclaimed: async (_p, payee) => void calls.push(`sync-unclaimed ${payee}`), + settleUnclaimed: async (_p, payee, _c, _f, rollTo) => void calls.push(`settle-unclaimed ${payee} ${rollTo}`), + setPayoutSchedule: ok("schedule"), + setCurrencyPolicy: async (currency, action, rollTo, afterDays) => void calls.push(`policy ${currency} ${action} ${rollTo ?? "-"} ${afterDays}`), + noteLinkExternal: async (code, accountId) => void calls.push(`note-external ${code} ${accountId}`), + noteLinkStatus: async (code, reason, username) => void calls.push(`note-status ${code} ${reason} ${username ?? "-"}`), + registerWallet: ok("register"), + setWalletStatus: async (linkId, canPull, verified) => void calls.push(`wallet-status ${linkId} ${canPull} ${verified}`), + setFunding: async row => void (fundingRows.push(row), calls.push(`funding ${row.craftId} ${row.state} ${row.reason ?? "-"}`)), + clearFunding: async craftId => void calls.push(`clear-funding ${craftId}`), + postFundingNotification: async (_account, payload) => void calls.push(`alert ${payload.kind} ${payload.role}`), + }; + return {writes, calls, intents, fundingRows}; +} + +/** Stelo as the bot sees it: the escrow wallet and one payer wallet, both on a scale-3 ledger. */ +function steloWorld(options: {walletRole?: string; walletBalance?: bigint} = {}) { + const cache = createSteloCache(); + for (const [id, role, balance] of [[ESCROW, "Write", 0n], [WALLET, options.walletRole ?? "Write", options.walletBalance ?? 1_000_000n], [RECEIVING, "Read", 0n]] as const) { + cache.accounts.insert({accountId: id, address: `A${id}`, kind: {tag: "Debit"}, balance, ledgerId: 1n, ledgerName: "Hex Coin", ledgerScale: 3, role: {tag: role}}); + } + // The payer's wallet is administered by a user whose BitCraft player (`PAYER_PLAYER`) belongs to `PAYER`. + cache.members.insert({id: 1n, accountId: WALLET, memberId: {toHexString: () => "payer-user"}, kind: {tag: "User"}, role: {tag: "Owner"}}); + cache.users.insert({id: {toHexString: () => "payer-user"}, bitcraftPlayerId: PAYER_PLAYER}); + return {cache, stelo: Object.assign(Object.create(cache), {isLive: true, createTransfer: async () => {}})}; +} + +const payerPlayers = () => new Map([[PAYER_PLAYER, PAYER]]); + +const link = (id: bigint, externalId: string): LinkedIntegration => ({ + id, accountIdentity: PAYER, provider: "stelo", externalId, externalHandle: undefined, linkedAt: ts(NOW), revokedAt: undefined, +}); + +function escrowWorld() { + const escrow = createEscrowCache(); + escrow.linkedIntegration.insert(link(1n, WALLET.toString())); + escrow.steloWallet.insert({linkId: 1n, accountIdentity: PAYER, currency: "stelo-hex-coin"} as SteloWallet); + return escrow; +} + +const request = (overrides: Partial = {}): FundingRequest => ({ + ratioNumerator: 1n, ratioDenominator: 20n, currency: "stelo-hex-coin", payer: PAYER, fundingLinkId: 1n, ...overrides, +}); + +function openEscrow(overrides: Partial = {}): CraftBountyEscrow { + return { + id: 1n, craftId: 7n, payerAccountIdentity: PAYER, currency: "stelo-hex-coin", fundingLinkId: 1n, fundingAccountId: WALLET, + heldAmount: 20n, releasedAmount: 0n, ratioNumerator: 1n, ratioDenominator: 20n, effortCovered: 400n, effortPriced: 0n, + accruedNumerator: 0n, accruedDenominator: 1n, heldAt: ts(NOW), updatedAt: ts(NOW), closedAt: undefined, ...overrides, + }; +} + +function transfer(id: bigint, purpose: SteloTransferPurpose, fields: Partial = {}): SteloTransfer { + return { + id, ref: `bx${id}`, currency: "stelo-hex-coin", payerAccountIdentity: PAYER, fromAccountId: WALLET, toAccountId: ESCROW, + amount: 20n, steloAmount: 20_000n, purpose, state: {tag: "Pending"}, steloTransferId: undefined, failureReason: undefined, + createdAt: ts(NOW), settledAt: undefined, ...fields, + }; +} + +const hold = (craftId: bigint): SteloTransferPurpose => ({ + tag: "Hold", value: {craftId, fundingLinkId: 1n, ratioNumerator: 1n, ratioDenominator: 20n, effortCovered: 400n}, +}); + +describe("classifyTransferError", () => { + it("maps Stelo's rejections to terminal failures and everything else to a retry", () => { + assert.equal(classifyTransferError(new Error("invalid balance")), "insufficient-funds"); + assert.equal(classifyTransferError(new Error("insufficient account permission")), "no-permission"); + assert.equal(classifyTransferError(new Error("sending account not found")), "invalid-account"); + assert.equal(classifyTransferError(new Error("incompatible account ledgers")), "invalid-account"); + assert.equal(classifyTransferError(new Error("connection reset")), null); + assert.equal(classifyTransferError(new Error("unavailable: stelo connection is not live")), null); + }); +}); + +describe("transfer executor", () => { + function setup(sendError?: Error) { + const {cache} = steloWorld(); + const sent: string[] = []; + const {writes, calls} = fakeWrites(); + let now = NOW; + const stelo = Object.assign(Object.create(cache), { + isLive: true, + createTransfer: async (args: {ref: string}) => { + sent.push(args.ref); + if (sendError) throw sendError; + }, + }); + const executor = createTransferExecutor({log: quiet, stelo, writes, now: () => now, resendAfterMs: 1000}); + return {cache, sent, calls, executor, advance: (ms: number) => void (now += ms)}; + } + const settle = () => new Promise(resolve => setImmediate(resolve)); + + it("sends a pending intent once, then again only after the resend delay", () => { + const {sent, executor, advance} = setup(); + const pending = [transfer(1n, hold(7n))]; + executor.run(pending); + executor.run(pending); + assert.deepEqual(sent, ["bx1"]); + advance(1500); + executor.run(pending); + assert.deepEqual(sent, ["bx1", "bx1"]); + }); + + it("settles instead of sending when the posted transfer is already visible (crash resume)", async () => { + const {cache, sent, calls, executor} = setup(); + cache.transferRows.insert({id: 55n, debitAccountId: ESCROW, creditAccountId: WALLET, postedAmount: 20_000n, state: {tag: "Posted"}, memo: "bx1"}); + executor.run([transfer(1n, hold(7n))]); + await settle(); + assert.deepEqual(sent, []); + assert.deepEqual(calls, ["settle 1 55"]); + }); + + it("ignores a posted transfer whose memo matches but accounts or amount do not", async () => { + const {cache, sent, calls, executor} = setup(); + cache.transferRows.insert({id: 55n, debitAccountId: ESCROW, creditAccountId: WALLET, postedAmount: 1n, state: {tag: "Posted"}, memo: "bx1"}); + cache.transferRows.insert({id: 56n, debitAccountId: WALLET, creditAccountId: ESCROW, postedAmount: 20_000n, state: {tag: "Posted"}, memo: "bx1"}); + executor.run([transfer(1n, hold(7n))]); + await settle(); + assert.deepEqual(sent, ["bx1"]); + assert.deepEqual(calls, []); + }); + + it("fails the intent when Stelo rejects it for good, and keeps it pending on a transient error", async () => { + const rejected = setup(new Error("invalid balance")); + rejected.executor.run([transfer(1n, hold(7n))]); + await settle(); + assert.deepEqual(rejected.calls, ["fail 1 insufficient-funds"]); + + const transient = setup(new Error("timed out")); + transient.executor.run([transfer(1n, hold(7n))]); + await settle(); + assert.deepEqual(transient.calls, []); + }); + + it("sends nothing while paused but still settles what already posted", async () => { + const {cache, sent, calls, executor} = setup(); + executor.paused = true; + cache.transferRows.insert({id: 55n, debitAccountId: ESCROW, creditAccountId: WALLET, postedAmount: 20_000n, state: {tag: "Posted"}, memo: "bx1"}); + executor.run([transfer(1n, hold(7n)), transfer(2n, hold(8n))]); + await settle(); + assert.deepEqual(sent, []); + assert.deepEqual(calls, ["settle 1 55"]); + }); +}); + +describe("funding gate", () => { + function setup(world = steloWorld(), escrow = escrowWorld(), now = NOW) { + const {writes, calls, intents} = fakeWrites(); + const gate = createFundingGate({config, escrow, stelo: world.stelo, playerAccounts: payerPlayers, writes, log: quiet, now: () => now}); + return {gate, escrow, calls, intents}; + } + + it("holds ceil(ratio × effort) from the named wallet, in raw Stelo units, and waits", () => { + const {gate, calls, intents} = setup(); + assert.equal(gate.prepare(7n, request(), 401n), "wait"); + assert.deepEqual(calls, ["intent Hold"]); + assert.equal(intents[0].amount, 21n); + assert.equal(intents[0].steloAmount, 21_000n); + assert.equal(intents[0].fromAccountId, WALLET); + assert.equal(intents[0].toAccountId, ESCROW); + // A second tick before the intent echoes back must not repeat it. + assert.equal(gate.prepare(7n, request(), 401n), "wait"); + assert.deepEqual(calls, ["intent Hold"]); + }); + + it("does not hold while an intent is pending, or when the wallet can't be pulled from or isn't the payer's", () => { + const escrow = escrowWorld(); + escrow.steloTransfer.insert(transfer(1n, hold(7n))); + const pending = setup(steloWorld(), escrow); + assert.equal(pending.gate.prepare(7n, request(), 400n), "wait"); + assert.deepEqual(pending.calls, []); + + const readOnly = setup(steloWorld({walletRole: "Read"})); + assert.equal(readOnly.gate.prepare(7n, request(), 400n), "wait"); + assert.deepEqual(readOnly.calls, []); + + const wrongPayer = setup(); + assert.equal(wrongPayer.gate.prepare(7n, request({payer: PAYEE_ACCOUNT}), 400n), "wait"); + const noWallet = setup(); + assert.equal(noWallet.gate.prepare(7n, request({fundingLinkId: undefined}), 400n), "wait"); + assert.deepEqual([wrongPayer.calls, noWallet.calls], [[], []]); + }); + + it("backs off after a failed hold, doubling per failure, from the stored history", () => { + const escrow = escrowWorld(); + escrow.steloTransfer.insert(transfer(1n, hold(7n), {state: {tag: "Failed"}, settledAt: ts(NOW)})); + const early = setup(steloWorld(), escrow, NOW + 500); + assert.equal(early.gate.prepare(7n, request(), 400n), "wait"); + assert.deepEqual(early.calls, []); + const later = setup(steloWorld(), escrow, NOW + 1500); + later.gate.prepare(7n, request(), 400n); + assert.deepEqual(later.calls, ["intent Hold"]); + + escrow.steloTransfer.insert(transfer(2n, hold(7n), {state: {tag: "Failed"}, settledAt: ts(NOW)})); + const doubled = setup(steloWorld(), escrow, NOW + 1500); + doubled.gate.prepare(7n, request(), 400n); + assert.deepEqual(doubled.calls, [], "two failures wait 2s, not 1s"); + }); + + it("is ready when the hold secures the ratio, and keeps a hold held for another payer", () => { + const escrow = escrowWorld(); + escrow.craftBountyEscrow.insert(openEscrow()); + const {gate, calls} = setup(steloWorld(), escrow); + assert.equal(gate.prepare(7n, request(), 400n), "ready"); + assert.equal(gate.prepare(7n, request({ratioNumerator: 1n, ratioDenominator: 30n}), 400n), "ready"); + assert.equal(gate.prepare(7n, request({payer: PAYEE_ACCOUNT}), 400n), "keep"); + assert.deepEqual(calls, []); + }); + + it("tops up a raise from the craft's own wallet, re-reserving refund due first", () => { + const escrow = escrowWorld(); + escrow.craftBountyEscrow.insert(openEscrow({accruedNumerator: 5n, effortPriced: 100n})); + // 1/20 -> 1/15 with 300 effort left needs 5 + 20 = 25 against 20 held: 5 more. + escrow.escrowWallet.insert({id: 1n, payerAccountIdentity: PAYER, currency: "stelo-hex-coin", fundingAccountId: WALLET, refundDue: 2n, updatedAt: ts(NOW)} as BountyEscrowWallet); + const {gate, calls, intents} = setup(steloWorld(), escrow); + assert.equal(gate.prepare(7n, request({ratioDenominator: 15n}), 400n), "wait"); + assert.deepEqual(calls, ["intent TopUp"]); + assert.equal(intents[0].amount, 3n); + assert.equal(intents[0].purpose.tag === "TopUp" && intents[0].purpose.value.reserved, 2n); + assert.equal(intents[0].fromAccountId, WALLET); + }); + + it("raises without a transfer when refund due already covers it", () => { + const escrow = escrowWorld(); + escrow.craftBountyEscrow.insert(openEscrow({accruedNumerator: 5n, effortPriced: 100n})); + escrow.escrowWallet.insert({id: 1n, payerAccountIdentity: PAYER, currency: "stelo-hex-coin", fundingAccountId: WALLET, refundDue: 9n, updatedAt: ts(NOW)} as BountyEscrowWallet); + const {gate, calls} = setup(steloWorld(), escrow); + assert.equal(gate.prepare(7n, request({ratioDenominator: 15n}), 400n), "wait"); + assert.deepEqual(calls, ["raise 7"]); + }); +}); + +describe("funding status", () => { + function setup(world = steloWorld(), escrow = escrowWorld(), now = NOW) { + const {writes, calls} = fakeWrites(); + const gate = createFundingGate({config, escrow, stelo: world.stelo, playerAccounts: payerPlayers, writes, log: quiet, now: () => now}); + return {gate, escrow, calls, writes}; + } + + it("is waiting with the reason prepare hit, then funding while the hold is pending", () => { + const readOnly = setup(steloWorld({walletRole: "Read"})); + readOnly.gate.prepare(7n, request(), 400n); + assert.deepEqual(readOnly.gate.status(7n, request()), {state: "waiting", reason: "cannot-pull"}); + + const noWallet = setup(); + noWallet.gate.prepare(7n, request({fundingLinkId: undefined}), 400n); + assert.deepEqual(noWallet.gate.status(7n, request({fundingLinkId: undefined})), {state: "waiting", reason: "no-wallet"}); + + const escrow = escrowWorld(); + escrow.steloTransfer.insert(transfer(1n, hold(7n))); + const pending = setup(steloWorld(), escrow); + pending.gate.prepare(7n, request(), 400n); + assert.deepEqual(pending.gate.status(7n, request()), {state: "funding", transferId: 1n}); + }); + + it("is failed with the transfer's reason during backoff, and funded once the hold secures the rate", () => { + const escrow = escrowWorld(); + escrow.steloTransfer.insert(transfer(1n, hold(7n), {state: {tag: "Failed"}, failureReason: "insufficient-funds", settledAt: ts(NOW)})); + const failed = setup(steloWorld(), escrow, NOW + 500); + failed.gate.prepare(7n, request(), 400n); + assert.deepEqual(failed.gate.status(7n, request()), {state: "failed", reason: "insufficient-funds", transferId: 1n}); + + const held = escrowWorld(); + held.craftBountyEscrow.insert(openEscrow()); + const funded = setup(steloWorld(), held); + funded.gate.prepare(7n, request(), 400n); + assert.deepEqual(funded.gate.status(7n, request()), {state: "funded"}); + funded.gate.prepare(7n, request({ratioDenominator: 15n}), 400n); + assert.equal(funded.gate.status(7n, request({ratioDenominator: 15n})).state, "waiting", "a raise is not secured yet"); + }); + + const subject = {source: "rule", ruleId: "r1", currency: "stelo-hex-coin", payer: PAYER, fundingLinkId: 1n, ratioNumerator: 1n, ratioDenominator: 20n} as const; + const settle = () => new Promise(resolve => setTimeout(resolve, 10)); + + it("writes a row only when it would change, and clears a row that has no intent", async () => { + const escrow = escrowWorld(); + const {writes, calls} = setup(); + const sync = createFundingStatusSync({escrow, writes, notifier: undefined, log: quiet, holdMs: 0}); + + sync.report(7n, subject, {state: "waiting", reason: "cannot-pull"}); + await settle(); + assert.deepEqual(calls, ["funding 7 waiting cannot-pull"]); + + escrow.craftBountyFunding.insert({ + craftId: 7n, payerAccountIdentity: PAYER, source: "rule", ruleId: "r1", currency: "stelo-hex-coin", fundingLinkId: 1n, + ratioNumerator: 1n, ratioDenominator: 20n, state: "waiting", reason: "cannot-pull", transferId: undefined, updatedAt: ts(NOW), + }); + sync.report(7n, subject, {state: "waiting", reason: "cannot-pull"}); + sync.clear(8n); + await settle(); + assert.equal(calls.length, 1, "an unchanged row and an unknown craft write nothing"); + + sync.clear(7n); + await settle(); + assert.deepEqual(calls.slice(1), ["clear-funding 7"]); + }); + + it("alerts once on the edge into failed or an actionable waiting reason, never on a repeat or a benign wait", async () => { + const escrow = escrowWorld(); + const {writes, calls} = setup(); + const notifier = createFundingNotifier({app: {connection: null} as never, escrow, stelo: steloWorld().cache, writes, log: quiet}); + const sync = createFundingStatusSync({escrow, writes, notifier, log: quiet, holdMs: 0}); + + sync.report(7n, subject, {state: "waiting", reason: "stelo-unavailable"}); + await settle(); + assert.deepEqual(calls, ["funding 7 waiting stelo-unavailable"], "stelo-unavailable is not actionable"); + + sync.report(7n, subject, {state: "failed", reason: "insufficient-funds", transferId: 1n}); + await settle(); + assert.deepEqual(calls.slice(1), ["funding 7 failed insufficient-funds", "alert funding-failed payer"]); + }); + + it("posts one alert per wallet and reason across crafts and retries, and again after the reason changes or the wallet recovers", async () => { + const escrow = escrowWorld(); + const {writes, calls} = setup(); + const notifier = createFundingNotifier({app: {connection: null} as never, escrow, stelo: steloWorld().cache, writes, log: quiet}); + const sync = createFundingStatusSync({escrow, writes, notifier, log: quiet, holdMs: 0}); + const alerts = () => calls.filter(call => call.startsWith("alert")).length; + const failed = (reason: "insufficient-funds" | "cannot-pull") => ({state: "failed", reason, transferId: 1n}) as const; + + sync.report(7n, subject, failed("insufficient-funds")); + sync.report(8n, subject, failed("insufficient-funds")); + await settle(); + assert.equal(alerts(), 1, "two crafts on one wallet share an alert"); + + // A retry cycle: funding, then failing again with the same reason. + sync.report(7n, subject, {state: "funding", transferId: 2n}); + await settle(); + sync.report(7n, subject, failed("insufficient-funds")); + await settle(); + assert.equal(alerts(), 1, "a retry with the same reason stays silent"); + + sync.report(7n, subject, failed("cannot-pull")); + await settle(); + assert.equal(alerts(), 2, "a new reason posts"); + sync.report(7n, subject, failed("insufficient-funds")); + await settle(); + assert.equal(alerts(), 3, "going back to the earlier reason posts again"); + + sync.report(7n, subject, {state: "funded"}); + await settle(); + sync.report(8n, subject, failed("insufficient-funds")); + await settle(); + assert.equal(alerts(), 4, "a recovered wallet alerts on its next failure"); + }); +}); + +describe("payout planner", () => { + function pool(overrides: Partial = {}): BountyEscrowPool { + return { + id: 1n, payerAccountIdentity: PAYER, payeePlayerId: 5001n, currency: "stelo-hex-coin", fundingAccountId: WALLET, + total: 7n, remainderNumerator: 3n, remainderDenominator: 20n, guaranteedTotal: 6n, + guaranteedRemainderNumerator: 1n, guaranteedRemainderDenominator: 2n, paidEscrow: 0n, paidDirect: 0n, pulledExtras: 0n, + unclaimedSince: undefined, updatedAt: ts(NOW), ...overrides, + }; + } + + function setup(options: {routable?: boolean; now?: number; schedule?: number; world?: ReturnType} = {}) { + const escrow = escrowWorld(); + const world = options.world ?? steloWorld(); + if (options.routable !== false) { + escrow.linkedIntegration.insert(link(3n, RECEIVING.toString())); + escrow.steloReceivingWallet.insert({id: 1n, accountIdentity: PAYEE_ACCOUNT, currency: "stelo-hex-coin", linkId: 3n} as SteloReceivingWallet); + } + if (options.schedule !== undefined) escrow.payoutSchedule.insert({id: 0, intervalMicros: 86_400_000_000n, lastSweepAt: ts(options.schedule)}); + const {writes, calls, intents} = fakeWrites(); + const planner = createPayoutPlanner({ + config, escrow, playerAccounts: () => new Map([[5001n, PAYEE_ACCOUNT], [PAYER_PLAYER, PAYER]]), stelo: world.stelo, writes, log: quiet, now: () => options.now ?? NOW, + }); + return {planner, escrow, calls, intents}; + } + + const extrasPull = (id: bigint, state: SteloTransfer["state"]) => + transfer(id, {tag: "Extras", value: {payeePlayerId: 5001n, fundingAccountId: WALLET}}, {fromAccountId: WALLET, toAccountId: ESCROW, amount: 1n, steloAmount: 1000n, state}); + + it("pulls the loyalty extras into escrow first, then pays the guaranteed part plus the extras in one transfer", () => { + const {planner, escrow, calls, intents} = setup(); + escrow.escrowPool.insert(pool()); + planner.sweep(); + assert.deepEqual(calls.sort(), ["intent Extras", "schedule"]); + assert.deepEqual([intents[0].fromAccountId, intents[0].toAccountId, intents[0].amount, intents[0].steloAmount], [WALLET, ESCROW, 1n, 1000n]); + + // Once the pull has settled the books show the extras in escrow, and the single payout carries them. + escrow.steloTransfer.insert(extrasPull(10n, {tag: "Settled"})); + escrow.escrowPool.update(pool(), pool({pulledExtras: 1n})); + planner.resume(true); + const payout = intents[1]; + assert.deepEqual([payout.fromAccountId, payout.toAccountId, payout.amount], [ESCROW, RECEIVING, 7n]); + assert.deepEqual(payout.purpose, {tag: "Payout", value: {payeePlayerId: 5001n, fundingAccountId: WALLET, extras: 1n}}); + }); + + it("still pays the guaranteed part when the extras can't be pulled, leaving them owed", () => { + const {planner, escrow, intents} = setup({world: steloWorld({walletBalance: 0n})}); + escrow.escrowPool.insert(pool()); + planner.sweep(); + assert.deepEqual(intents.map(i => [i.purpose.tag, i.amount]), [["Payout", 6n]]); + }); + + it("pays the guaranteed part once a failed extras pull is seen, and waits while it is pending", () => { + const {planner, escrow, intents} = setup(); + escrow.escrowPool.insert(pool()); + planner.sweep(); + escrow.steloTransfer.insert(extrasPull(10n, {tag: "Pending"})); + planner.resume(true); + assert.equal(intents.length, 1, "nothing is paid while the pull is pending"); + + escrow.steloTransfer.update(extrasPull(10n, {tag: "Pending"}), extrasPull(10n, {tag: "Failed"})); + planner.resume(true); + assert.deepEqual(intents.slice(1).map(i => [i.purpose.tag, i.amount]), [["Payout", 6n]]); + }); + + it("pays out extras already in escrow that no payout is carrying (restart)", () => { + const {planner, escrow, intents} = setup(); + escrow.escrowPool.insert(pool({pulledExtras: 1n})); + planner.resume(true); + assert.deepEqual(intents.map(i => [i.purpose.tag, i.amount]), [["Payout", 7n]]); + }); + + it("does nothing before the interval has passed", () => { + const {planner, escrow, calls} = setup({schedule: NOW - 1000}); + escrow.escrowPool.insert(pool()); + planner.sweep(); + assert.deepEqual(calls, []); + }); + + it("flags an unroutable payee, then closes it out by rolling over once the policy's days have passed", () => { + const first = setup({routable: false}); + first.escrow.escrowPool.insert(pool()); + first.planner.sweep(); + assert.deepEqual(first.calls, ["sync-unclaimed 5001", "schedule"]); + + const later = setup({routable: false, now: NOW + 8 * 86_400_000}); + later.escrow.escrowPool.insert(pool({unclaimedSince: ts(NOW)})); + later.planner.sweep(); + assert.deepEqual(later.calls, ["settle-unclaimed 5001 hex-coin", "schedule"]); + + const tooSoon = setup({routable: false, now: NOW + 3 * 86_400_000}); + tooSoon.escrow.escrowPool.insert(pool({unclaimedSince: ts(NOW)})); + tooSoon.planner.sweep(); + assert.deepEqual(tooSoon.calls, ["schedule"]); + }); + + it("refunds released headroom to the wallet that funded it, minus what is already being refunded", () => { + const {planner, escrow, intents} = setup(); + escrow.escrowWallet.insert({id: 1n, payerAccountIdentity: PAYER, currency: "stelo-hex-coin", fundingAccountId: WALLET, refundDue: 5n, updatedAt: ts(NOW)} as BountyEscrowWallet); + escrow.steloTransfer.insert(transfer(9n, {tag: "Refund", value: {fundingAccountId: WALLET}}, {amount: 2n, fromAccountId: ESCROW, toAccountId: WALLET})); + planner.refunds(); + assert.deepEqual(intents.map(i => [i.amount, i.fromAccountId, i.toAccountId]), [[3n, ESCROW, WALLET]]); + }); + + it("asks the module to reclassify a direct payment that became guaranteed", () => { + const {planner, escrow, calls} = setup(); + escrow.escrowPool.insert(pool({guaranteedTotal: 7n, paidEscrow: 6n, paidDirect: 1n})); + planner.overlaps(); + assert.deepEqual(calls, ["overlap"]); + }); +}); + +describe("reconcile", () => { + it("matches the wallet against the journal plus transfers posted but not yet settled", () => { + const {cache} = steloWorld(); + const escrow = createEscrowCache(); + escrow.escrowJournal.insert({id: 1n, payerAccountIdentity: PAYER, currency: "stelo-hex-coin", kind: "hold", amount: 20n, fundingAccountId: WALLET, craftId: 7n, payeePlayerId: undefined, transferId: 1n, createdAt: ts(NOW)}); + assert.deepEqual(findEscrowDrift(config, escrow.state, cache).map(d => [d.expected, d.actual]), [[20_000n, 0n]]); + + cache.accounts.update(cache.account(ESCROW)!, {...cache.account(ESCROW)!, balance: 20_000n}); + assert.deepEqual(findEscrowDrift(config, escrow.state, cache), []); + + // A payout posted on Stelo but not yet settled in the books has already left the wallet. + escrow.steloTransfer.insert(transfer(2n, {tag: "Payout", value: {payeePlayerId: 5001n, fundingAccountId: WALLET, extras: 0n}}, {fromAccountId: ESCROW, toAccountId: RECEIVING, amount: 6n, steloAmount: 6000n})); + cache.transferRows.insert({id: 70n, debitAccountId: RECEIVING, creditAccountId: ESCROW, postedAmount: 6000n, state: {tag: "Posted"}, memo: "bx2"}); + cache.accounts.update(cache.account(ESCROW)!, {...cache.account(ESCROW)!, balance: 14_000n}); + assert.deepEqual(findEscrowDrift(config, escrow.state, cache), []); + }); + + it("flags transfers on an escrow wallet that no intent explains", () => { + const {cache} = steloWorld(); + const escrow = createEscrowCache(); + escrow.steloTransfer.insert(transfer(1n, hold(7n))); + cache.transferRows.insert({id: 1n, debitAccountId: ESCROW, creditAccountId: WALLET, postedAmount: 20_000n, state: {tag: "Posted"}, memo: "bx1"}); + cache.transferRows.insert({id: 2n, debitAccountId: ESCROW, creditAccountId: WALLET, postedAmount: 5n, state: {tag: "Posted"}, memo: "hello"}); + cache.transferRows.insert({id: 3n, debitAccountId: 1n, creditAccountId: 2n, postedAmount: 5n, state: {tag: "Posted"}, memo: undefined}); + assert.deepEqual(findUnknownEscrowTransfers(config, escrow.state, cache).map(t => t.id), [2n]); + }); + + it("does not count a stray deposit as drift, and nets its return", () => { + const {cache} = steloWorld(); + const escrow = createEscrowCache(); + escrow.escrowJournal.insert({id: 1n, payerAccountIdentity: PAYER, currency: "stelo-hex-coin", kind: "hold", amount: 20n, fundingAccountId: WALLET, craftId: 7n, payeePlayerId: undefined, transferId: 1n, createdAt: ts(NOW)}); + cache.accounts.update(cache.account(ESCROW)!, {...cache.account(ESCROW)!, balance: 20_000n}); + + cache.transferRows.insert({id: 80n, debitAccountId: ESCROW, creditAccountId: WALLET, postedAmount: 500n, state: {tag: "Posted"}, memo: undefined}); + cache.accounts.update(cache.account(ESCROW)!, {...cache.account(ESCROW)!, balance: 20_500n}); + assert.deepEqual(findEscrowDrift(config, escrow.state, cache), []); + + cache.transferRows.insert({id: 81n, debitAccountId: WALLET, creditAccountId: ESCROW, postedAmount: 500n, state: {tag: "Posted"}, memo: "returned"}); + cache.accounts.update(cache.account(ESCROW)!, {...cache.account(ESCROW)!, balance: 20_000n}); + assert.deepEqual(findEscrowDrift(config, escrow.state, cache), []); + + // Real drift on top of the stray transfers is still caught. + cache.accounts.update(cache.account(ESCROW)!, {...cache.account(ESCROW)!, balance: 19_000n}); + assert.equal(findEscrowDrift(config, escrow.state, cache).length, 1); + }); + + it("does not let a copied memo hide a transfer from a different account", () => { + const {cache} = steloWorld(); + const escrow = createEscrowCache(); + escrow.steloTransfer.insert(transfer(1n, hold(7n))); + cache.transferRows.insert({id: 90n, debitAccountId: ESCROW, creditAccountId: 999n, postedAmount: 20_000n, state: {tag: "Posted"}, memo: "bx1"}); + assert.deepEqual(findUnknownEscrowTransfers(config, escrow.state, cache).map(t => t.id), [90n]); + }); +}); + +describe("funding authorization", () => { + it("waits with not-authorized when the linker no longer administers the wallet", () => { + const world = steloWorld(); + world.cache.members.delete({id: 1n, accountId: WALLET, memberId: {toHexString: () => "payer-user"}, kind: {tag: "User"}, role: {tag: "Owner"}}); + const {writes, calls} = fakeWrites(); + const gate = createFundingGate({config, escrow: escrowWorld(), stelo: world.stelo, playerAccounts: payerPlayers, writes, log: quiet, now: () => NOW}); + assert.equal(gate.prepare(7n, request(), 400n), "wait"); + assert.deepEqual(calls, []); + assert.deepEqual(gate.status(7n, request()), {state: "waiting", reason: "not-authorized"}); + }); +}); + +describe("link processor", () => { + const settle = () => new Promise(resolve => setTimeout(resolve, 10)); + + function setup(request: Partial) { + const world = steloWorld(); + world.cache.ledgers.insert({id: 1n, name: "Hex Coin", scale: 3}); + const escrow = createEscrowCache(); + const stelo = Object.assign(Object.create(world.cache), { + isLive: true, + searchAddress: async () => [{accountId: 303n, address: "FAR", ledgerId: 1n, kind: {tag: "Debit"}, primaryUsername: "Far"}], + lookupAccount: async () => undefined, + }); + const {writes, calls} = fakeWrites(); + const processor = createLinkProcessor({config, escrow, playerAccounts: payerPlayers, stelo, writes, log: quiet, holdMs: 0}); + const row = { + id: 1n, code: "c1", provider: "stelo", accountIdentity: PAYER, externalId: undefined, externalHandle: undefined, + candidateAddress: "FAR", pendingReason: undefined, unverified: true, matchedUsername: undefined, confirmed: false, ...request, + } as IntegrationLinkRequest; + escrow.integrationLinkRequest.insert(row); + return {processor, escrow, calls, row}; + } + + it("matches an unverified address in the directory, shows the username, and finalizes only after confirmation", async () => { + const {processor, escrow, calls, row} = setup({}); + processor.run(); + await settle(); + processor.run(); + await settle(); + assert.deepEqual(calls, ["note-status c1 confirm-unverified Far"]); + + escrow.integrationLinkRequest.update(row, {...row, pendingReason: "confirm-unverified", matchedUsername: "Far", confirmed: true}); + processor.run(); + await settle(); + assert.deepEqual(calls.slice(1), ["note-external c1 303"]); + }); + + it("reports a failure reason instead of a match for a verified-only request the bot cannot see", async () => { + const {processor, calls} = setup({unverified: false}); + processor.run(); + await settle(); + assert.deepEqual(calls, ["note-status c1 bot-not-member -"]); + }); +}); + +describe("direct transfers", () => { + const TARGET = 303n; + + function setup(options: {walletRole?: string; walletBalance?: bigint; targetLedger?: bigint} = {}) { + const {stelo: base} = steloWorld(options); + const stelo = Object.assign(Object.create(base), { + lookupAccount: async (id: bigint) => id === WALLET || id === TARGET + ? {accountId: id, address: `A${id}`, ledgerId: id === TARGET ? options.targetLedger ?? 1n : 1n, kind: {tag: "Debit"}, primaryUsername: `user${id}`} + : undefined, + }); + const {writes, intents} = fakeWrites(); + const direct = createDirectTransfers({config, escrow: escrowWorld(), playerAccounts: payerPlayers, stelo, writes}); + return {direct, intents}; + } + + const args = (overrides: Partial["direct"]["prepare"]>[0]> = {}) => + ({payer: PAYER, currency: "stelo-hex-coin", fromAccountId: WALLET, toAccountId: TARGET, amount: 5n, ...overrides}); + + it("lists only wallets the bot can send from", () => { + assert.deepEqual(setup().direct.sendableWallets(PAYER, "stelo-hex-coin"), [{accountId: WALLET, address: `A${WALLET}`}]); + assert.deepEqual(setup({walletRole: "Read"}).direct.sendableWallets(PAYER, "stelo-hex-coin"), []); + assert.deepEqual(setup().direct.sendableWallets(PAYER, "hex-coin"), []); + }); + + it("prepares a transfer with both wallets' primary usernames and records it as a Direct intent", async () => { + const {direct, intents} = setup(); + const result = await direct.prepare(args()); + assert.ok(result.ok); + assert.equal(result.plan.from.primaryUsername, `user${WALLET}`); + assert.equal(result.plan.to.primaryUsername, `user${TARGET}`); + assert.equal(result.plan.steloAmount, 5_000n); + + await direct.submit(result.plan); + assert.equal(intents.length, 1); + assert.equal(intents[0].purpose.tag, "Direct"); + assert.deepEqual([intents[0].fromAccountId, intents[0].toAccountId, intents[0].amount, intents[0].steloAmount], [WALLET, TARGET, 5n, 5_000n]); + }); + + it("rejects a source wallet that is not the payer's, a missing or foreign-ledger destination and an overdraft", async () => { + const {direct} = setup(); + assert.equal((await direct.prepare(args({fromAccountId: RECEIVING}))).ok, false); + assert.equal((await direct.prepare(args({toAccountId: 999n}))).ok, false); + assert.equal((await direct.prepare(args({toAccountId: WALLET}))).ok, false); + assert.equal((await direct.prepare(args({amount: 1_001n}))).ok, false); + assert.equal((await setup({targetLedger: 2n}).direct.prepare(args())).ok, false); + }); + + describe("destination search", () => { + const hit = (accountId: bigint, address: string, primaryUsername?: string) => + ({accountId, address, ledgerId: 1n, kind: {tag: "Debit"}, primaryUsername}); + const HITS = [hit(1n, "ALPHA", "alice"), hit(2n, "ALICE2"), hit(3n, "BETA", "bob")]; + + function searchSetup() { + const {cache, stelo: base} = steloWorld(); + cache.ledgers.insert({id: 1n, name: "Hex Coin", scale: 3}); + const calls: [string, string | undefined][] = []; + const stelo = Object.assign(Object.create(base), { + searchAccounts: async (term: string, _ledger: bigint, scope?: string) => { + calls.push([term, scope]); + return HITS.filter(h => scope === "Username" ? h.primaryUsername?.startsWith(term.toLowerCase()) : scope === "Address" + ? h.address.toLowerCase().startsWith(term.toLowerCase()) + : h.address.toLowerCase().startsWith(term.toLowerCase()) || h.primaryUsername?.startsWith(term.toLowerCase())); + }, + }); + const direct = createDirectTransfers({config, escrow: escrowWorld(), playerAccounts: payerPlayers, stelo, writes: fakeWrites().writes}); + return {direct, calls}; + } + + it("passes the @ and # scope through to the search", async () => { + const {direct, calls} = searchSetup(); + assert.deepEqual((await direct.searchWallets("stelo-hex-coin", "@ali")).map(h => h.accountId), [1n]); + assert.deepEqual(calls, [["ali", "Username"]]); + await direct.searchWallets("stelo-hex-coin", "#al"); + await direct.searchWallets("stelo-hex-coin", "al"); + assert.deepEqual(calls.slice(1), [["al", "Address"], ["al", undefined]]); + }); + + it("resolves only an exact, unambiguous match", async () => { + const {direct} = searchSetup(); + assert.deepEqual(await direct.resolveWallet("stelo-hex-coin", "@alice"), {ok: true, accountId: 1n}); + assert.equal((await direct.resolveWallet("stelo-hex-coin", "@ali")).ok, false); + assert.deepEqual(await direct.resolveWallet("stelo-hex-coin", "#alice2"), {ok: true, accountId: 2n}); + assert.deepEqual(await direct.resolveWallet("stelo-hex-coin", "beta"), {ok: true, accountId: 3n}); + assert.equal((await direct.resolveWallet("stelo-hex-coin", "")).ok, false); + }); + + it("labels a primary account by user and address, any other by address", () => { + assert.equal(walletLabel({address: "ALPHA", primaryUsername: "alice"}), "@alice (#ALPHA)"); + assert.equal(walletLabel({address: "ALICE2", primaryUsername: undefined}), "#ALICE2"); + }); + }); +}); diff --git a/backend/brico-bot/src/stelo/writes.ts b/backend/brico-bot/src/stelo/writes.ts new file mode 100644 index 00000000..64d57f2f --- /dev/null +++ b/backend/brico-bot/src/stelo/writes.ts @@ -0,0 +1,128 @@ +/** + * writes.ts — the `brico-app` reducer calls the Stelo step makes, behind an interface so the + * funding/payout logic can be tested without a connection. + */ +import type {SteloTransferPurpose} from "@brico/bindings/brico-app/types"; +import type {Identity} from "spacetimedb"; +import {Timestamp} from "spacetimedb"; + +import type {BricoAppConnection} from "../app/connection.ts"; +import {timeReducerCall} from "../metrics.ts"; + +export interface TransferIntentArgs { + currency: string; + payer: Identity; + fromAccountId: bigint; + toAccountId: bigint; + /** Whole brico units. */ + amount: bigint; + /** Raw Stelo units, frozen here so a replay is byte-identical. */ + steloAmount: bigint; + purpose: SteloTransferPurpose; +} + +/** One `craft_bounty_funding` row (everything but `updatedAt`). */ +export interface FundingRow { + craftId: bigint; + payer: Identity; + source: string; + ruleId: string | undefined; + currency: string; + fundingLinkId: bigint | undefined; + ratioNumerator: bigint; + ratioDenominator: bigint; + state: string; + reason: string | undefined; + transferId: bigint | undefined; +} + +/** A funding notification's payload. */ +export interface FundingAlert { + kind: string; + role: string; + currency: string; + craftId: bigint | undefined; + amount: bigint | undefined; + reason: string | undefined; + payerName: string | undefined; + payeeName: string | undefined; +} + +export interface SteloWrites { + recordIntent(args: TransferIntentArgs): Promise; + settleTransfer(id: bigint, steloTransferId: bigint): Promise; + failTransfer(id: bigint, reason: string): Promise; + raiseFromRefundDue(craftId: bigint, ratioNumerator: bigint, ratioDenominator: bigint): Promise; + closeCraftEscrow(craftId: bigint): Promise; + releaseOverlap(payer: Identity, payeePlayerId: bigint, currency: string): Promise; + syncUnclaimed(payer: Identity, payeePlayerId: bigint, currency: string, fundingAccountId: bigint): Promise; + settleUnclaimed(payer: Identity, payeePlayerId: bigint, currency: string, fundingAccountId: bigint, rollTo: string | undefined): Promise; + setPayoutSchedule(intervalMicros: bigint, lastSweepAt: Timestamp): Promise; + setCurrencyPolicy(currency: string, action: string, rollTo: string | undefined, afterDays: number): Promise; + noteLinkExternal(code: string, accountId: bigint, address: string): Promise; + noteLinkStatus(code: string, reason: string, matchedUsername: string | undefined): Promise; + registerWallet(linkId: bigint, currency: string): Promise; + setWalletStatus(linkId: bigint, canPull: boolean, verified: boolean): Promise; + setFunding(row: FundingRow): Promise; + clearFunding(craftId: bigint): Promise; + postFundingNotification(account: Identity, payload: FundingAlert): Promise; +} + +/** The writes over the live `brico-app` connection; each rejects if the connection is down. */ +export function createSteloWrites(app: BricoAppConnection, refPrefix: string): SteloWrites { + const conn = () => { + const live = app.connection?.connection; + if (!live?.isActive || !app.isLive) throw new Error("unavailable: brico-app connection is not live"); + return live; + }; + return { + recordIntent: args => timeReducerCall("record_stelo_transfer_intent", conn().reducers.recordSteloTransferIntent({ + refPrefix, + currency: args.currency, + payerAccountIdentity: args.payer, + fromAccountId: args.fromAccountId, + toAccountId: args.toAccountId, + amount: args.amount, + steloAmount: args.steloAmount, + purpose: args.purpose, + })), + settleTransfer: (id, steloTransferId) => timeReducerCall("settle_stelo_transfer", conn().reducers.settleSteloTransfer({id, steloTransferId})), + failTransfer: (id, reason) => timeReducerCall("fail_stelo_transfer", conn().reducers.failSteloTransfer({id, reason})), + raiseFromRefundDue: (craftId, ratioNumerator, ratioDenominator) => + timeReducerCall("raise_craft_escrow_from_refund_due", conn().reducers.raiseCraftEscrowFromRefundDue({craftId, ratioNumerator, ratioDenominator})), + closeCraftEscrow: craftId => timeReducerCall("close_craft_escrow", conn().reducers.closeCraftEscrow({craftId})), + releaseOverlap: (payer, payeePlayerId, currency) => + timeReducerCall("release_escrow_overlap", conn().reducers.releaseEscrowOverlap({payerAccountIdentity: payer, payeePlayerId, currency})), + syncUnclaimed: (payer, payeePlayerId, currency, fundingAccountId) => + timeReducerCall("sync_escrow_pool_unclaimed", conn().reducers.syncEscrowPoolUnclaimed({payerAccountIdentity: payer, payeePlayerId, currency, fundingAccountId})), + settleUnclaimed: (payer, payeePlayerId, currency, fundingAccountId, rollTo) => + timeReducerCall("settle_unclaimed_payee", conn().reducers.settleUnclaimedPayee({payerAccountIdentity: payer, payeePlayerId, currency, fundingAccountId, rollTo})), + setPayoutSchedule: (intervalMicros, lastSweepAt) => + timeReducerCall("set_stelo_payout_schedule", conn().reducers.setSteloPayoutSchedule({intervalMicros, lastSweepAt})), + setCurrencyPolicy: (currency, action, rollTo, afterDays) => + timeReducerCall("set_stelo_currency_policy", conn().reducers.setSteloCurrencyPolicy({currency, action, rollTo, afterDays})), + noteLinkExternal: (code, accountId, address) => + timeReducerCall("note_integration_link_external", conn().reducers.noteIntegrationLinkExternal({code, externalId: accountId.toString(), externalHandle: address})), + noteLinkStatus: (code, reason, matchedUsername) => + timeReducerCall("note_integration_link_status", conn().reducers.noteIntegrationLinkStatus({code, reason, matchedUsername})), + registerWallet: (linkId, currency) => timeReducerCall("register_stelo_wallet", conn().reducers.registerSteloWallet({linkId, currency})), + setWalletStatus: (linkId, canPull, verified) => + timeReducerCall("set_stelo_wallet_status", conn().reducers.setSteloWalletStatus({linkId, canPull, verified})), + setFunding: row => timeReducerCall("set_craft_bounty_funding", conn().reducers.setCraftBountyFunding({ + craftId: row.craftId, + payerAccountIdentity: row.payer, + source: row.source, + ruleId: row.ruleId, + currency: row.currency, + fundingLinkId: row.fundingLinkId, + ratioNumerator: row.ratioNumerator, + ratioDenominator: row.ratioDenominator, + state: row.state, + reason: row.reason, + transferId: row.transferId, + })), + clearFunding: craftId => timeReducerCall("clear_craft_bounty_funding", conn().reducers.clearCraftBountyFunding({craftId})), + postFundingNotification: (account, payload) => + timeReducerCall("post_funding_notification", conn().reducers.postFundingNotification({accountIdentity: account, payload})), + }; +} diff --git a/backend/brico-module/spacetimedb/src/index.ts b/backend/brico-module/spacetimedb/src/index.ts index 3b4ce62c..0e8e6c0b 100644 --- a/backend/brico-module/spacetimedb/src/index.ts +++ b/backend/brico-module/spacetimedb/src/index.ts @@ -8,6 +8,7 @@ * - notifications: generic, craft-sourced notifications * - bounties: bounty rules, per-craft overrides and resolved assignments * - payouts: entitlement ledgers, payment records and manual loyalty rewards + * - escrow: Stelo escrow holds, transfer intents, per-wallet entitlement pools and wallets * - discord: Discord bot watch displays, notification sinks and guild installs * - notify: per-(filter, sink) notification triggers */ @@ -20,6 +21,7 @@ export * from './reducers/crafts'; export * from './reducers/notifications'; export * from './reducers/bounties'; export * from './reducers/payouts'; +export * from './reducers/escrow'; export * from './reducers/discord'; export * from './reducers/notify'; @@ -29,5 +31,6 @@ export * from './views/crafts'; export * from './views/notifications'; export * from './views/bounties'; export * from './views/payouts'; +export * from './views/escrow'; export * from './views/discord'; export * from './views/notify'; diff --git a/backend/brico-module/spacetimedb/src/lib/bounty.ts b/backend/brico-module/spacetimedb/src/lib/bounty.ts index ec456073..9cbfb444 100644 --- a/backend/brico-module/spacetimedb/src/lib/bounty.ts +++ b/backend/brico-module/spacetimedb/src/lib/bounty.ts @@ -1,5 +1,5 @@ +import {BOUNTY_CURRENCIES, MANUALLY_RECORDED_BOUNTY_CURRENCIES} from "@brico/crafts/currencies"; import {CraftError} from '@brico/crafts/errors'; -import {BOUNTY_CURRENCIES, MANUALLY_RECORDED_BOUNTY_CURRENCIES} from '@brico/crafts/filter'; import {SenderError} from 'spacetimedb/server'; const BOUNTY_CURRENCY_SET = new Set(BOUNTY_CURRENCIES); diff --git a/backend/brico-module/spacetimedb/src/lib/escrow.ts b/backend/brico-module/spacetimedb/src/lib/escrow.ts new file mode 100644 index 00000000..5dfb1c14 --- /dev/null +++ b/backend/brico-module/spacetimedb/src/lib/escrow.ts @@ -0,0 +1,270 @@ +import {ESCROWED_BOUNTY_CURRENCIES} from "@brico/crafts/currencies"; +import {ceilRatio, escrowRequired, type Fraction, splitPayout} from '@brico/crafts/entitlement'; +import {CraftError} from '@brico/crafts/errors'; +import type {Identity, Timestamp} from 'spacetimedb'; +import {SenderError} from 'spacetimedb/server'; +import type {Ctx} from '../schema'; + +export const JOURNAL_KINDS = ['hold', 'topup', 'extras', 'payout', 'refund'] as const; +export type JournalKind = (typeof JOURNAL_KINDS)[number]; + +const ESCROWED_CURRENCY_SET = new Set(ESCROWED_BOUNTY_CURRENCIES); + +export function isEscrowedCurrency(currency: string): boolean { + return ESCROWED_CURRENCY_SET.has(currency); +} + +export function requireEscrowedCurrency(currency: string): void { + if (!isEscrowedCurrency(currency)) throw new SenderError(`${CraftError.CURRENCY_NOT_ESCROWED}:${currency}`); +} + +export type EscrowRow = NonNullable>; +export type PoolRow = NonNullable>; + +/** `a ≥ b` for non-negative fractions. */ +export function ratioAtLeast(a: Fraction, b: Fraction): boolean { + return a.numerator * b.denominator >= b.numerator * a.denominator; +} + +export function findOpenEscrow(ctx: Ctx, craftId: bigint): EscrowRow | null { + for (const row of ctx.db.craft_bounty_escrow.craftId.filter(craftId)) { + if (row.closedAt === undefined) return row; + } + return null; +} + +export function escrowRatio(escrow: EscrowRow): Fraction { + return {numerator: escrow.ratioNumerator, denominator: escrow.ratioDenominator}; +} + +export function escrowAccrued(escrow: EscrowRow): Fraction { + return {numerator: escrow.accruedNumerator, denominator: escrow.accruedDenominator}; +} + +/** The Stelo account id behind an active `stelo` link, or null if the link isn't one. */ +function steloAccountId(link: {externalId: string}): bigint | null { + return /^\d{1,20}$/.test(link.externalId) ? BigInt(link.externalId) : null; +} + +/** + * The Stelo account id of one of `accountIdentity`'s active wallets on `currency` that may fund + * bounties; throws `INVALID_FUNDING_WALLET` if the link is unknown, revoked, someone else's, not a + * registered wallet or on another currency's ledger, and `FUNDING_WALLET_UNVERIFIED` if the bot has + * not verified that the linker administers it. + */ +export function requireActiveSteloWallet(ctx: Ctx, accountIdentity: Identity, linkId: bigint, currency: string): bigint { + const link = ctx.db.linked_integration.id.find(linkId); + const wallet = ctx.db.stelo_wallet.linkId.find(linkId); + const accountId = link === null ? null : steloAccountId(link); + if (link === null || wallet === null || accountId === null + || link.provider !== 'stelo' || link.revokedAt !== undefined + || !link.accountIdentity.isEqual(accountIdentity) || wallet.currency !== currency) { + throw new SenderError(CraftError.INVALID_FUNDING_WALLET); + } + if (ctx.db.stelo_wallet_status.linkId.find(linkId)?.verified !== true) { + throw new SenderError(CraftError.FUNDING_WALLET_UNVERIFIED); + } + return accountId; +} + +/** Throws `INVALID_FUNDING_WALLET` unless `accountId` is one of `accountIdentity`'s active, verified wallets on `currency`. */ +export function requireOwnSteloAccount(ctx: Ctx, accountIdentity: Identity, accountId: bigint, currency: string): void { + for (const wallet of ctx.db.stelo_wallet.accountIdentity.filter(accountIdentity)) { + if (wallet.currency !== currency) continue; + const link = ctx.db.linked_integration.id.find(wallet.linkId); + if (link === null || link.provider !== 'stelo' || link.revokedAt !== undefined || steloAccountId(link) !== accountId) continue; + if (ctx.db.stelo_wallet_status.linkId.find(wallet.linkId)?.verified === true) return; + } + throw new SenderError(CraftError.INVALID_FUNDING_WALLET); +} + +/** The Stelo account that should receive `payeePlayerId`'s payouts in `currency`, or null if they are unroutable. */ +export function routePayeeWallet(ctx: Ctx, payeePlayerId: bigint, currency: string): bigint | null { + for (const link of ctx.db.linked_integration.by_provider_external_id.filter(['bitcraft-ea2', payeePlayerId.toString()])) { + if (link.revokedAt !== undefined) continue; + for (const receiving of ctx.db.stelo_receiving_wallet.by_account_currency.filter([link.accountIdentity, currency])) { + const walletLink = ctx.db.linked_integration.id.find(receiving.linkId); + if (walletLink === null || walletLink.revokedAt !== undefined) continue; + return steloAccountId(walletLink); + } + } + return null; +} + +export function findEscrowWallet(ctx: Ctx, payer: Identity, currency: string, fundingAccountId: bigint) { + return [...ctx.db.bounty_escrow_wallet.by_payer_currency.filter([payer, currency])] + .find(row => row.fundingAccountId === fundingAccountId) ?? null; +} + +/** Adds `amount` to what `fundingAccountId` is owed back from escrow, creating its row on first use. */ +export function addRefundDue(ctx: Ctx, payer: Identity, currency: string, fundingAccountId: bigint, amount: bigint): void { + if (amount === 0n) return; + const existing = findEscrowWallet(ctx, payer, currency, fundingAccountId); + if (existing === null) { + ctx.db.bounty_escrow_wallet.insert({ + id: 0n, payerAccountIdentity: payer, currency, fundingAccountId, refundDue: amount, updatedAt: ctx.timestamp, + }); + return; + } + ctx.db.bounty_escrow_wallet.id.update({...existing, refundDue: existing.refundDue + amount, updatedAt: ctx.timestamp}); +} + +/** + * Refund due that is free to move: not already covered by an unsettled refund and not reserved by an + * unsettled top-up. + */ +export function reservableRefundDue(ctx: Ctx, payer: Identity, currency: string, fundingAccountId: bigint): bigint { + const wallet = findEscrowWallet(ctx, payer, currency, fundingAccountId); + if (wallet === null) return 0n; + let free = wallet.refundDue; + for (const transfer of ctx.db.stelo_transfer.payerAccountIdentity.filter(payer)) { + if (transfer.state.tag !== 'pending' || transfer.currency !== currency) continue; + if (transfer.purpose.tag === 'refund' && transfer.purpose.value.fundingAccountId === fundingAccountId) { + free -= transfer.amount; + } else if (transfer.purpose.tag === 'topUp' && transfer.fromAccountId === fundingAccountId) { + free -= transfer.purpose.value.reserved; + } + } + return free > 0n ? free : 0n; +} + +/** Moves `amount` units of a craft's hold from "reserved for crafters" to its wallet's refund due. */ +export function releaseFromEscrow(ctx: Ctx, escrow: EscrowRow, amount: bigint): EscrowRow { + if (amount <= 0n) return escrow; + addRefundDue(ctx, escrow.payerAccountIdentity, escrow.currency, escrow.fundingAccountId, amount); + const updated = {...escrow, releasedAmount: escrow.releasedAmount + amount, updatedAt: ctx.timestamp}; + ctx.db.craft_bounty_escrow.id.update(updated); + return updated; +} + +/** Releases whatever of the open hold the craft hasn't accrued and closes it; no-op if none is open. */ +export function closeOpenEscrow(ctx: Ctx, craftId: bigint): void { + const escrow = findOpenEscrow(ctx, craftId); + if (escrow === null) return; + const accruedCeil = ceilRatio(escrowAccrued(escrow)); + const released = releaseFromEscrow(ctx, escrow, escrow.heldAmount - escrow.releasedAmount - accruedCeil); + ctx.db.craft_bounty_escrow.id.update({...released, closedAt: ctx.timestamp, updatedAt: ctx.timestamp}); +} + +/** Lowers a hold's secured ratio and releases the headroom it no longer needs. */ +export function cutEscrowRatio(ctx: Ctx, escrow: EscrowRow, ratio: Fraction): void { + const required = escrowRequired(escrowAccrued(escrow), ratio, escrow.effortCovered, escrow.effortPriced); + const surplus = escrow.heldAmount - escrow.releasedAmount - required; + const released = releaseFromEscrow(ctx, escrow, surplus); + ctx.db.craft_bounty_escrow.id.update({ + ...released, ratioNumerator: ratio.numerator, ratioDenominator: ratio.denominator, updatedAt: ctx.timestamp, + }); +} + +export function findPool(ctx: Ctx, payer: Identity, payeePlayerId: bigint, currency: string, fundingAccountId: bigint): PoolRow | null { + return [...ctx.db.bounty_escrow_pool.by_payer_payee.filter([payer, payeePlayerId])] + .find(row => row.currency === currency && row.fundingAccountId === fundingAccountId) ?? null; +} + +/** + * Moves pulled extras the pool no longer owes (its guaranteed total grew past them, or it is paid up) + * to its paying wallet's refund due. Call after any change to the pool's totals; returns the pool as stored. + */ +export function releaseSurplusExtras(ctx: Ctx, pool: PoolRow): PoolRow { + const surplus = pool.pulledExtras - splitPayout(pool).fromWallet; + if (surplus <= 0n) return pool; + addRefundDue(ctx, pool.payerAccountIdentity, pool.currency, pool.fundingAccountId, surplus); + const updated = {...pool, pulledExtras: pool.pulledExtras - surplus, updatedAt: ctx.timestamp}; + ctx.db.bounty_escrow_pool.id.update(updated); + return updated; +} + +export function poolFractions(pool: PoolRow): {full: Fraction; guaranteed: Fraction} { + return { + full: {numerator: pool.remainderNumerator, denominator: pool.remainderDenominator}, + guaranteed: {numerator: pool.guaranteedRemainderNumerator, denominator: pool.guaranteedRemainderDenominator}, + }; +} + +export function journal( + ctx: Ctx, + entry: { + payer: Identity; + currency: string; + kind: JournalKind; + amount: bigint; + fundingAccountId: bigint; + craftId?: bigint; + payeePlayerId?: bigint; + transferId: bigint; + }, +): void { + ctx.db.bounty_escrow_journal.insert({ + id: 0n, + payerAccountIdentity: entry.payer, + currency: entry.currency, + kind: entry.kind, + amount: entry.amount, + fundingAccountId: entry.fundingAccountId, + craftId: entry.craftId, + payeePlayerId: entry.payeePlayerId, + transferId: entry.transferId, + createdAt: ctx.timestamp, + }); +} + +/** Adds `delta` to the surfaced (payer, payee, currency) `bounty_entitlement_total.total`, creating the row if needed. */ +export function addSurfacedTotal( + ctx: Ctx, payer: Identity, payeePlayerId: bigint, currency: string, delta: bigint, deltaEffort: bigint, updatedAt: Timestamp, +): void { + const existing = [...ctx.db.bounty_entitlement_total.by_payer_payee.filter([payer, payeePlayerId])] + .find(row => row.currency === currency); + if (existing === undefined) { + ctx.db.bounty_entitlement_total.insert({ + id: 0n, payerAccountIdentity: payer, payeePlayerId, currency, + total: delta, totalEffort: deltaEffort, remainderNumerator: 0n, remainderDenominator: 1n, updatedAt, + }); + } else { + ctx.db.bounty_entitlement_total.id.update({ + ...existing, total: existing.total + delta, totalEffort: existing.totalEffort + deltaEffort, updatedAt, + }); + } +} + +/** Adds `amount` to the surfaced `bounty_payout_record.paidTotal` and appends a log row of the given `source`. */ +export function addSurfacedPayment( + ctx: Ctx, payer: Identity, payeePlayerId: bigint, currency: string, amount: bigint, source: string, +): void { + const existing = [...ctx.db.bounty_payout_record.by_payer_payee.filter([payer, payeePlayerId])] + .find(row => row.currency === currency); + if (existing === undefined) { + ctx.db.bounty_payout_record.insert({ + id: 0n, payerAccountIdentity: payer, payeePlayerId, currency, paidTotal: amount, updatedAt: ctx.timestamp, + }); + } else { + ctx.db.bounty_payout_record.id.update({...existing, paidTotal: existing.paidTotal + amount, updatedAt: ctx.timestamp}); + } + ctx.db.bounty_payout_record_log.insert({ + id: 0n, payerAccountIdentity: payer, payeePlayerId, currency, amount, createdAt: ctx.timestamp, source, + }); +} + +/** Drops the wallet rows of a `stelo` link that is being revoked. Shared by `unlinkIntegration`. */ +export function removeSteloWallet(ctx: Ctx, linkId: bigint): void { + const wallet = ctx.db.stelo_wallet.linkId.find(linkId); + if (wallet === null) return; + ctx.db.stelo_wallet.linkId.delete(linkId); + ctx.db.stelo_wallet_status.linkId.delete(linkId); + for (const receiving of [...ctx.db.stelo_receiving_wallet.by_account_currency.filter([wallet.accountIdentity, wallet.currency])]) { + if (receiving.linkId === linkId) ctx.db.stelo_receiving_wallet.id.delete(receiving.id); + } +} + +/** + * Checks a rule's or override's `fundingLinkId` against its currency: an escrowed currency needs one + * of the caller's own active wallets on that currency; any other currency must not name a wallet. + * Whether the bot can actually pull from the wallet is checked live by the bot. + */ +export function validateFundingWallet(ctx: Ctx, caller: Identity, currency: string, fundingLinkId: bigint | undefined): void { + if (!isEscrowedCurrency(currency)) { + if (fundingLinkId !== undefined) throw new SenderError(CraftError.FUNDING_WALLET_NOT_ALLOWED); + return; + } + if (fundingLinkId === undefined) throw new SenderError(CraftError.FUNDING_WALLET_REQUIRED); + requireActiveSteloWallet(ctx, caller, fundingLinkId, currency); +} diff --git a/backend/brico-module/spacetimedb/src/lib/notify.ts b/backend/brico-module/spacetimedb/src/lib/notify.ts new file mode 100644 index 00000000..770ae1e4 --- /dev/null +++ b/backend/brico-module/spacetimedb/src/lib/notify.ts @@ -0,0 +1,27 @@ +import {CraftError} from '@brico/crafts/errors'; +import type {Identity} from 'spacetimedb'; +import type {Infer} from 'spacetimedb/server'; +import {SenderError} from 'spacetimedb/server'; +import type {Ctx} from '../schema'; +import type {NotifySinkRef} from '../tables/notify'; + +type Sink = Infer; + +/** Throws unless a Discord `sink` is one of `account`'s live notification sinks; a toast sink always passes. */ +export function requireOwnDiscordSink(ctx: Ctx, account: Identity, sink: Sink): void { + if (sink.tag !== 'discord') return; + const discordSink = ctx.db.discord_notify_sink.id.find(sink.value.sinkId); + if (discordSink === null || !discordSink.accountIdentity.isEqual(account) || discordSink.deletedAt !== undefined) { + throw new SenderError(CraftError.UNKNOWN_DISCORD_NOTIFY_SINK); + } +} + +/** `account`'s live funding trigger for `sink`, if any. */ +export function findFundingTrigger(ctx: Ctx, account: Identity, sink: Sink) { + for (const row of ctx.db.funding_notify_trigger.accountIdentity.filter(account)) { + if (row.deletedAt !== undefined || row.sink.tag !== sink.tag) continue; + if (row.sink.tag === 'discord' && sink.tag === 'discord' && row.sink.value.sinkId !== sink.value.sinkId) continue; + return row; + } + return null; +} diff --git a/backend/brico-module/spacetimedb/src/reducers/bounties.ts b/backend/brico-module/spacetimedb/src/reducers/bounties.ts index 4e72b436..bd5b3740 100644 --- a/backend/brico-module/spacetimedb/src/reducers/bounties.ts +++ b/backend/brico-module/spacetimedb/src/reducers/bounties.ts @@ -5,6 +5,7 @@ import type {FilterField} from '@brico/crafts/filter'; import {SenderError, t} from 'spacetimedb/server'; import {requireAccount, requireServicePrincipal} from '../lib/auth'; import {validateBountyCurrency, validateRatio} from '../lib/bounty'; +import {closeOpenEscrow, cutEscrowRatio, escrowRatio, findOpenEscrow, isEscrowedCurrency, ratioAtLeast, validateFundingWallet} from '../lib/escrow'; import {requireValidFilterJson} from '../lib/filters'; import {clampClientTimestamp, isAtLeastAsNew} from '../lib/sync'; import {spacetimedb} from '../schema'; @@ -32,8 +33,16 @@ const AssignmentBaseline = t.object('AssignmentBaseline', { * `upsertSavedCraftFilter`. */ export const upsertBountyRule = spacetimedb.reducer( - {id: t.string(), filterJson: t.string(), value: BountyRuleValue, priority: t.i32(), private: t.bool(), updatedAt: t.timestamp()}, - (ctx, {id, filterJson, value, priority, private: isPrivate, updatedAt}) => { + { + id: t.string(), + filterJson: t.string(), + value: BountyRuleValue, + priority: t.i32(), + private: t.bool(), + updatedAt: t.timestamp(), + fundingLinkId: t.option(t.u64()), + }, + (ctx, {id, filterJson, value, priority, private: isPrivate, updatedAt, fundingLinkId}) => { requireAccount(ctx); if (filterJson.length > MAX_FILTER_JSON_LENGTH) { throw new SenderError(CraftError.FILTER_TOO_LARGE); @@ -45,6 +54,7 @@ export const upsertBountyRule = spacetimedb.reducer( } else { for (const cell of value.value.cells) validateRatio(cell.ratioNumerator, cell.ratioDenominator); } + validateFundingWallet(ctx, ctx.sender, value.value.currency, fundingLinkId); const existing = ctx.db.bounty_rule.id.find(id); if (existing !== null && !existing.accountIdentity.isEqual(ctx.sender)) { @@ -65,6 +75,7 @@ export const upsertBountyRule = spacetimedb.reducer( createdAt: ctx.timestamp, updatedAt: clampedUpdatedAt, deletedAt: undefined, + fundingLinkId, }); } else { ctx.db.bounty_rule.id.update({ @@ -75,6 +86,7 @@ export const upsertBountyRule = spacetimedb.reducer( private: isPrivate, updatedAt: clampedUpdatedAt, deletedAt: undefined, + fundingLinkId, }); } } @@ -148,11 +160,13 @@ export const upsertCraftBountyOverride = spacetimedb.reducer( currency: t.string(), private: t.bool(), updatedAt: t.timestamp(), + fundingLinkId: t.option(t.u64()), }, - (ctx, {craftId, ratioNumerator, ratioDenominator, currency, private: isPrivate, updatedAt}) => { + (ctx, {craftId, ratioNumerator, ratioDenominator, currency, private: isPrivate, updatedAt, fundingLinkId}) => { requireAccount(ctx); validateBountyCurrency(currency); validateRatio(ratioNumerator, ratioDenominator); + validateFundingWallet(ctx, ctx.sender, currency, fundingLinkId); const existing = ctx.db.craft_bounty_override.craftId.find(craftId); if (existing !== null && !existing.accountIdentity.isEqual(ctx.sender)) { @@ -171,6 +185,7 @@ export const upsertCraftBountyOverride = spacetimedb.reducer( currency, private: isPrivate, updatedAt: clampedUpdatedAt, + fundingLinkId, }); } else { ctx.db.craft_bounty_override.craftId.update({ @@ -180,6 +195,7 @@ export const upsertCraftBountyOverride = spacetimedb.reducer( currency, private: isPrivate, updatedAt: clampedUpdatedAt, + fundingLinkId, }); } } @@ -215,15 +231,25 @@ export const deleteCraftBountyOverride = spacetimedb.reducer( * close. * * `baselines` (contributors' effort before this bounty) is applied only when this call creates the - * assignment: each listed player without a `craft_bounty_entitlement` row gets one at that effort - * (with `assignedAt` as `updatedAt`, so it is not seen as a gap), so `upsertCraftBountyEntitlement` - * prices only later effort. Omit players whose existing effort should be paid in full. + * assignment or changes its currency: each listed player without a `craft_bounty_entitlement` row in + * the new currency gets one at that effort (with `assignedAt` as `updatedAt`, so it is not seen as a + * gap), so `upsertCraftBountyEntitlement` prices only later effort. Omit players whose existing effort + * should be paid in full. A currency change also moves forward such a row left from an earlier stint + * in that currency, and moving off an escrowed currency closes the craft's hold. * * `private` (sourced from whichever rule/override matched) selects which of the two mutually * exclusive assignment tables gets the row — the other table's row for this craft, if any, is * deleted, so a craft never has a resolved assignment in both at once (e.g. a rule that used to be * public and is now marked private). Switching tables is itself a delete-then-insert, so it resets * `assignedAt` in the table gaining the row — same as any other clear-then-reassign. + * + * For an escrowed currency the craft must already have an open hold of the same payer and currency + * (`craft_bounty_escrow`) securing at least `ratio`: a higher ratio is rejected, and a lower one + * cuts the hold in this same transaction, releasing the surplus. `ratio` is always the base ratio; + * loyalty never reaches this table. + * + * `sticky` is false for an assignment resolved from the craft's override and true for one from a + * rule; `brico-bot` keeps a sticky assignment against rule edits and deletions. */ export const assignCraftBounty = spacetimedb.reducer( { @@ -236,20 +262,43 @@ export const assignCraftBounty = spacetimedb.reducer( assignedAt: t.timestamp(), updatedAt: t.timestamp(), baselines: t.array(AssignmentBaseline), + sticky: t.bool(), }, - (ctx, {craftId, ratioNumerator, ratioDenominator, currency, assignedByAccountIdentity, private: isPrivate, assignedAt, updatedAt, baselines}) => { + (ctx, {craftId, ratioNumerator, ratioDenominator, currency, assignedByAccountIdentity, private: isPrivate, assignedAt, updatedAt, baselines, sticky}) => { requireServicePrincipal(ctx); validateBountyCurrency(currency); if (ctx.db.account.identity.find(assignedByAccountIdentity) === null) { throw new SenderError(CraftError.UNKNOWN_BRICO_ACCOUNT); } - if (ctx.db.craft_bounty_assignment.craftId.find(craftId) === null - && ctx.db.craft_private_bounty_assignment.craftId.find(craftId) === null) { + if (isEscrowedCurrency(currency)) { + const escrow = findOpenEscrow(ctx, craftId); + if (escrow === null || escrow.currency !== currency || !escrow.payerAccountIdentity.isEqual(assignedByAccountIdentity)) { + throw new SenderError(CraftError.ESCROW_REQUIRED); + } + const ratio = {numerator: ratioNumerator, denominator: ratioDenominator}; + if (!ratioAtLeast(escrowRatio(escrow), ratio)) throw new SenderError(CraftError.ESCROW_RATIO_EXCEEDED); + if (!ratioAtLeast(ratio, escrowRatio(escrow))) cutEscrowRatio(ctx, escrow, ratio); + } + + const current = ctx.db.craft_bounty_assignment.craftId.find(craftId) ?? ctx.db.craft_private_bounty_assignment.craftId.find(craftId); + if (current === null) { for (const {playerId, effort} of baselines) { if ([...ctx.db.craft_bounty_entitlement.by_craft_player_currency.filter([craftId, playerId, currency])].length > 0) continue; ctx.db.craft_bounty_entitlement.insert({id: 0n, craftId, playerId, currency, lastAssignedEffort: effort, updatedAt: assignedAt}); } + } else if (current.currency !== currency) { + // Switching currency: effort so far belongs to the old currency, so the new one starts from + // `baselines`, moving forward a row left over from an earlier stint in this currency. + for (const {playerId, effort} of baselines) { + const row = [...ctx.db.craft_bounty_entitlement.by_craft_player_currency.filter([craftId, playerId, currency])][0]; + if (row === undefined) { + ctx.db.craft_bounty_entitlement.insert({id: 0n, craftId, playerId, currency, lastAssignedEffort: effort, updatedAt: assignedAt}); + } else if (effort > row.lastAssignedEffort) { + ctx.db.craft_bounty_entitlement.id.update({...row, lastAssignedEffort: effort, updatedAt: assignedAt}); + } + } + if (!isEscrowedCurrency(currency)) closeOpenEscrow(ctx, craftId); } if (isPrivate) { @@ -259,11 +308,11 @@ export const assignCraftBounty = spacetimedb.reducer( const existing = ctx.db.craft_private_bounty_assignment.craftId.find(craftId); if (existing === null) { ctx.db.craft_private_bounty_assignment.insert({ - craftId, ratioNumerator, ratioDenominator, currency, assignedByAccountIdentity, assignedAt, updatedAt, + craftId, ratioNumerator, ratioDenominator, currency, assignedByAccountIdentity, assignedAt, updatedAt, sticky, }); } else { ctx.db.craft_private_bounty_assignment.craftId.update({ - ...existing, ratioNumerator, ratioDenominator, currency, assignedByAccountIdentity, updatedAt, + ...existing, ratioNumerator, ratioDenominator, currency, assignedByAccountIdentity, updatedAt, sticky, }); } } else { @@ -273,18 +322,18 @@ export const assignCraftBounty = spacetimedb.reducer( const existing = ctx.db.craft_bounty_assignment.craftId.find(craftId); if (existing === null) { ctx.db.craft_bounty_assignment.insert({ - craftId, ratioNumerator, ratioDenominator, currency, assignedByAccountIdentity, assignedAt, updatedAt, + craftId, ratioNumerator, ratioDenominator, currency, assignedByAccountIdentity, assignedAt, updatedAt, sticky, }); } else { ctx.db.craft_bounty_assignment.craftId.update({ - ...existing, ratioNumerator, ratioDenominator, currency, assignedByAccountIdentity, updatedAt, + ...existing, ratioNumerator, ratioDenominator, currency, assignedByAccountIdentity, updatedAt, sticky, }); } } } ); -/** Written by `brico-bot` when a craft no longer resolves to any override or matching rule, or the craft no longer exists in prism (post-24-hour-expiry). Clears both the public and private assignment tables — the bot doesn't need to know which one held the row. */ +/** Written by `brico-bot` when a craft no longer resolves to any override or matching rule, or the craft no longer exists in prism (post-24-hour-expiry). Clears both the public and private assignment tables — the bot doesn't need to know which one held the row. Also releases and closes the craft's escrow hold, if it has one. */ export const clearCraftBounty = spacetimedb.reducer( {craftId: t.u64()}, (ctx, {craftId}) => { @@ -295,5 +344,6 @@ export const clearCraftBounty = spacetimedb.reducer( if (ctx.db.craft_private_bounty_assignment.craftId.find(craftId) !== null) { ctx.db.craft_private_bounty_assignment.craftId.delete(craftId); } + closeOpenEscrow(ctx, craftId); } ); diff --git a/backend/brico-module/spacetimedb/src/reducers/discord.ts b/backend/brico-module/spacetimedb/src/reducers/discord.ts index e5ff5fb8..acbdfbb3 100644 --- a/backend/brico-module/spacetimedb/src/reducers/discord.ts +++ b/backend/brico-module/spacetimedb/src/reducers/discord.ts @@ -13,10 +13,13 @@ import { } from '@brico/crafts/discord-display'; import {isDiscordCommandMode} from '@brico/crafts/discord-guild'; import {CraftError, MAX_DISPLAY_TEMPLATE_LENGTH, MAX_NOTIFY_TEMPLATE_LENGTH} from '@brico/crafts/errors'; +import type {Identity} from 'spacetimedb'; import type {Infer} from 'spacetimedb/server'; import {SenderError, t} from 'spacetimedb/server'; import {requireAccount, requireServicePrincipal} from '../lib/auth'; +import {findFundingTrigger, requireOwnDiscordSink} from '../lib/notify'; import {clampClientTimestamp, isAtLeastAsNew} from '../lib/sync'; +import type {Ctx} from '../schema'; import {spacetimedb} from '../schema'; import {DiscordWatchDisplayContent} from '../tables/discord'; @@ -321,7 +324,7 @@ export const detachDiscordNotifyTarget = spacetimedb.reducer( ); /** - * Service-principal writer for `/watch notify-setup`: upserts the `discord_notify_target` row + * Service-principal writer for `/crafts notify-setup`: upserts the `discord_notify_target` row * (including `mention*`) and the matching `craft_filter_notify_trigger` row by * `(accountIdentity, filterId, discordSinkId)`. Each trigger bool is on iff its template is * non-empty. `id` is used for whichever row(s) get inserted. @@ -448,3 +451,125 @@ export const upsertDiscordGuildInstall = spacetimedb.reducer( } } ); + +function findFundingTarget(ctx: Ctx, account: Identity, sinkId: string) { + for (const row of ctx.db.discord_funding_notify_target.accountIdentity.filter(account)) { + if (row.sinkId === sinkId && row.deletedAt === undefined) return row; + } + return null; +} + +/** + * Self-service edit of a Discord sink's funding-alert wording. Never touches `mention*` (bot-set) or + * `funding_notify_trigger`. An empty template stores as `undefined` (the default). + */ +export const upsertDiscordFundingTargetTemplate = spacetimedb.reducer( + { + id: t.string(), + discordSinkId: t.string(), + fundingFailedTemplate: t.string(), + payoutShortfallTemplate: t.string(), + unclaimedTemplate: t.string(), + updatedAt: t.timestamp(), + }, + (ctx, {id, discordSinkId, fundingFailedTemplate, payoutShortfallTemplate, unclaimedTemplate, updatedAt}) => { + requireAccount(ctx); + requireOwnDiscordSink(ctx, ctx.sender, {tag: 'discord', value: {sinkId: discordSinkId}}); + validateNotifyTemplateLength(fundingFailedTemplate); + validateNotifyTemplateLength(payoutShortfallTemplate); + validateNotifyTemplateLength(unclaimedTemplate); + + const existing = findFundingTarget(ctx, ctx.sender, discordSinkId); + const clampedUpdatedAt = clampClientTimestamp(ctx, updatedAt); + if (existing !== null && !isAtLeastAsNew(clampedUpdatedAt, existing.updatedAt)) return; + + const templates = { + fundingFailedTemplate: storedTemplate(fundingFailedTemplate), + payoutShortfallTemplate: storedTemplate(payoutShortfallTemplate), + unclaimedTemplate: storedTemplate(unclaimedTemplate), + }; + if (existing === null) { + ctx.db.discord_funding_notify_target.insert({ + id, accountIdentity: ctx.sender, sinkId: discordSinkId, ...templates, + mentionType: undefined, mentionId: undefined, mentionName: undefined, + updatedAt: clampedUpdatedAt, deletedAt: undefined, + }); + } else { + ctx.db.discord_funding_notify_target.id.update({...existing, ...templates, updatedAt: clampedUpdatedAt, deletedAt: undefined}); + } + } +); + +/** Tombstones the caller's own Discord funding target. Silent no-op if already gone. */ +export const detachDiscordFundingTarget = spacetimedb.reducer( + {id: t.string(), deletedAt: t.timestamp()}, + (ctx, {id, deletedAt}) => { + requireAccount(ctx); + const existing = ctx.db.discord_funding_notify_target.id.find(id); + if (existing === null) return; + if (!existing.accountIdentity.isEqual(ctx.sender)) { + throw new SenderError(CraftError.DISCORD_FUNDING_NOTIFY_TARGET_BELONGS_TO_ANOTHER_ACCOUNT); + } + const clampedDeletedAt = clampClientTimestamp(ctx, deletedAt); + if (!isAtLeastAsNew(clampedDeletedAt, existing.updatedAt)) return; + ctx.db.discord_funding_notify_target.id.update({...existing, updatedAt: clampedDeletedAt, deletedAt: clampedDeletedAt}); + } +); + +/** + * Service-principal writer for `/funding notify-setup`: upserts the `discord_funding_notify_target` row + * (including `mention*`) and the matching `funding_notify_trigger` row for one Discord sink. Each + * event is on iff its template is non-empty. + */ +export const attachDiscordFundingTarget = spacetimedb.reducer( + { + id: t.string(), + accountIdentity: t.identity(), + discordSinkId: t.string(), + fundingFailedTemplate: t.string(), + payoutShortfallTemplate: t.string(), + unclaimedTemplate: t.string(), + mentionType: t.option(t.string()), + mentionId: t.option(t.string()), + mentionName: t.option(t.string()), + }, + (ctx, {id, accountIdentity, discordSinkId, fundingFailedTemplate, payoutShortfallTemplate, unclaimedTemplate, mentionType, mentionId, mentionName}) => { + requireServicePrincipal(ctx); + const sinkRef = {tag: 'discord', value: {sinkId: discordSinkId}} as const; + requireOwnDiscordSink(ctx, accountIdentity, sinkRef); + validateNotifyTemplateLength(fundingFailedTemplate); + validateNotifyTemplateLength(payoutShortfallTemplate); + validateNotifyTemplateLength(unclaimedTemplate); + + const templates = { + fundingFailedTemplate: storedTemplate(fundingFailedTemplate), + payoutShortfallTemplate: storedTemplate(payoutShortfallTemplate), + unclaimedTemplate: storedTemplate(unclaimedTemplate), + }; + const existingTarget = findFundingTarget(ctx, accountIdentity, discordSinkId); + if (existingTarget === null) { + ctx.db.discord_funding_notify_target.insert({ + id, accountIdentity, sinkId: discordSinkId, ...templates, mentionType, mentionId, mentionName, + updatedAt: ctx.timestamp, deletedAt: undefined, + }); + } else { + ctx.db.discord_funding_notify_target.id.update({ + ...existingTarget, ...templates, mentionType, mentionId, mentionName, updatedAt: ctx.timestamp, + }); + } + + const flags = { + fundingFailed: templates.fundingFailedTemplate !== undefined, + payoutShortfall: templates.payoutShortfallTemplate !== undefined, + unclaimed: templates.unclaimedTemplate !== undefined, + }; + const existingTrigger = findFundingTrigger(ctx, accountIdentity, sinkRef); + if (existingTrigger === null) { + ctx.db.funding_notify_trigger.insert({ + id, accountIdentity, sink: sinkRef, ...flags, updatedAt: ctx.timestamp, deletedAt: undefined, + }); + } else { + ctx.db.funding_notify_trigger.id.update({...existingTrigger, ...flags, updatedAt: ctx.timestamp, deletedAt: undefined}); + } + } +); diff --git a/backend/brico-module/spacetimedb/src/reducers/escrow.ts b/backend/brico-module/spacetimedb/src/reducers/escrow.ts new file mode 100644 index 00000000..d83a26b1 --- /dev/null +++ b/backend/brico-module/spacetimedb/src/reducers/escrow.ts @@ -0,0 +1,597 @@ +// noinspection JSUnusedGlobalSymbols + +import {ceilRatio, escrowRequired, overlapRelease, reduceRatio, splitPayout} from '@brico/crafts/entitlement'; +import {CraftError} from '@brico/crafts/errors'; +import {FUNDING_REASONS, FUNDING_SOURCES, FUNDING_STATES} from '@brico/crafts/stelo'; +import type {Identity} from 'spacetimedb'; +import {SenderError, t} from 'spacetimedb/server'; +import {requireAccount, requireServicePrincipal} from '../lib/auth'; +import {validateManualBountyCurrency, validateRatio} from '../lib/bounty'; +import { + addRefundDue, + addSurfacedPayment, + addSurfacedTotal, + closeOpenEscrow, + escrowAccrued, + escrowRatio, + findEscrowWallet, + findOpenEscrow, + findPool, + journal, + ratioAtLeast, + releaseSurplusExtras, + requireActiveSteloWallet, + requireEscrowedCurrency, + requireOwnSteloAccount, + reservableRefundDue, + routePayeeWallet, +} from '../lib/escrow'; +import type {Ctx} from '../schema'; +import {spacetimedb} from '../schema'; +import {SteloTransferPurpose} from '../tables/escrow'; + +const REF_PREFIX_PATTERN = /^[a-z0-9]{1,8}$/i; + +function invalidTransfer(reason: string): SenderError { + return new SenderError(`${CraftError.INVALID_STELO_TRANSFER}:${reason}`); +} + +/** The payer's transfers that haven't settled or failed yet. */ +function pendingTransfers(ctx: Ctx, payer: Identity) { + return [...ctx.db.stelo_transfer.payerAccountIdentity.filter(payer)].filter(row => row.state.tag === 'pending'); +} + +// --------------------------------------------------------------------------- +// Transfer intents +// --------------------------------------------------------------------------- + +/** + * Service-principal-only: writes the `Pending` row for a Stelo transfer *before* `brico-bot` attempts + * it. Every argument is checked against the books regardless of what the bot sends; the row's `ref` + * (``) is the memo and idempotency key for the transfer. + */ +export const recordSteloTransferIntent = spacetimedb.reducer( + { + refPrefix: t.string(), + currency: t.string(), + payerAccountIdentity: t.identity(), + fromAccountId: t.u64(), + toAccountId: t.u64(), + amount: t.i64(), + steloAmount: t.u64(), + purpose: SteloTransferPurpose, + }, + (ctx, {refPrefix, currency, payerAccountIdentity, fromAccountId, toAccountId, amount, steloAmount, purpose}) => { + requireServicePrincipal(ctx); + requireEscrowedCurrency(currency); + if (!REF_PREFIX_PATTERN.test(refPrefix)) throw invalidTransfer('ref-prefix'); + if (amount <= 0n || steloAmount <= 0n) throw invalidTransfer('amount'); + if (ctx.db.account.identity.find(payerAccountIdentity) === null) throw new SenderError(CraftError.UNKNOWN_BRICO_ACCOUNT); + + const pending = pendingTransfers(ctx, payerAccountIdentity); + + switch (purpose.tag) { + case 'hold': { + const {craftId, fundingLinkId, ratioNumerator, ratioDenominator, effortCovered} = purpose.value; + validateRatio(ratioNumerator, ratioDenominator); + if (effortCovered <= 0n) throw invalidTransfer('effort'); + if (findOpenEscrow(ctx, craftId) !== null + || pending.some(row => row.purpose.tag === 'hold' && row.purpose.value.craftId === craftId)) { + throw new SenderError(CraftError.ESCROW_ALREADY_FUNDED); + } + if (requireActiveSteloWallet(ctx, payerAccountIdentity, fundingLinkId, currency) !== fromAccountId) { + throw new SenderError(CraftError.INVALID_FUNDING_WALLET); + } + if (amount !== ceilRatio(reduceRatio(ratioNumerator * effortCovered, ratioDenominator))) throw invalidTransfer('hold-amount'); + break; + } + case 'topUp': { + const {craftId, ratioNumerator, ratioDenominator, reserved} = purpose.value; + validateRatio(ratioNumerator, ratioDenominator); + const escrow = findOpenEscrow(ctx, craftId); + if (escrow === null) throw new SenderError(CraftError.NO_OPEN_ESCROW); + if (!escrow.payerAccountIdentity.isEqual(payerAccountIdentity) || escrow.currency !== currency) throw invalidTransfer('escrow-owner'); + const ratio = {numerator: ratioNumerator, denominator: ratioDenominator}; + if (ratioAtLeast(escrowRatio(escrow), ratio)) throw invalidTransfer('not-a-raise'); + if (pending.some(row => row.purpose.tag === 'topUp' && row.purpose.value.craftId === craftId)) throw invalidTransfer('top-up-pending'); + if (fromAccountId !== escrow.fundingAccountId) throw invalidTransfer('funding-wallet'); + const need = escrowRequired(escrowAccrued(escrow), ratio, escrow.effortCovered, escrow.effortPriced) + - (escrow.heldAmount - escrow.releasedAmount); + const reservable = reservableRefundDue(ctx, payerAccountIdentity, currency, escrow.fundingAccountId); + if (need <= 0n || reserved < 0n || reserved > reservable || reserved > need || amount !== need - reserved) { + throw invalidTransfer('top-up-amount'); + } + break; + } + case 'payout': { + const {payeePlayerId, fundingAccountId, extras} = purpose.value; + const pool = findPool(ctx, payerAccountIdentity, payeePlayerId, currency, fundingAccountId); + if (pool === null) throw new SenderError(CraftError.UNKNOWN_ESCROW_POOL); + if (pending.some(row => row.purpose.tag === 'payout' + && row.purpose.value.payeePlayerId === payeePlayerId + && row.purpose.value.fundingAccountId === fundingAccountId)) { + throw new SenderError(CraftError.PAYOUT_ALREADY_PENDING); + } + const split = splitPayout(pool); + if (extras < 0n || extras > pool.pulledExtras || extras > split.fromWallet || amount - extras > split.fromEscrow) { + throw invalidTransfer('payout-amount'); + } + if (routePayeeWallet(ctx, payeePlayerId, currency) !== toAccountId) throw invalidTransfer('payee-wallet'); + break; + } + case 'extras': { + const {payeePlayerId, fundingAccountId} = purpose.value; + const pool = findPool(ctx, payerAccountIdentity, payeePlayerId, currency, fundingAccountId); + if (pool === null) throw new SenderError(CraftError.UNKNOWN_ESCROW_POOL); + if (pending.some(row => row.purpose.tag === 'extras' + && row.purpose.value.payeePlayerId === payeePlayerId + && row.purpose.value.fundingAccountId === fundingAccountId)) { + throw new SenderError(CraftError.PAYOUT_ALREADY_PENDING); + } + if (fromAccountId !== fundingAccountId) throw invalidTransfer('funding-wallet'); + if (amount + pool.pulledExtras > splitPayout(pool).fromWallet) throw invalidTransfer('extras-amount'); + if (routePayeeWallet(ctx, payeePlayerId, currency) === null) throw invalidTransfer('payee-wallet'); + break; + } + case 'refund': { + const {fundingAccountId} = purpose.value; + if (toAccountId !== fundingAccountId) throw invalidTransfer('refund-destination'); + if (amount > reservableRefundDue(ctx, payerAccountIdentity, currency, fundingAccountId)) { + throw invalidTransfer('refund-amount'); + } + break; + } + case 'direct': { + if (fromAccountId === toAccountId) throw invalidTransfer('same-wallet'); + requireOwnSteloAccount(ctx, payerAccountIdentity, fromAccountId, currency); + break; + } + } + + const row = ctx.db.stelo_transfer.insert({ + id: 0n, + ref: `~${ctx.timestamp.microsSinceUnixEpoch}`, + currency, + payerAccountIdentity, + fromAccountId, + toAccountId, + amount, + steloAmount, + purpose, + state: {tag: 'pending'}, + steloTransferId: undefined, + failureReason: undefined, + createdAt: ctx.timestamp, + settledAt: undefined, + }); + ctx.db.stelo_transfer.id.update({...row, ref: `${refPrefix}${row.id}`}); + } +); + +/** Service-principal-only: records that the Stelo transfer for intent `id` posted, and applies its effect on the books in the same transaction. Idempotent. */ +export const settleSteloTransfer = spacetimedb.reducer( + {id: t.u64(), steloTransferId: t.u64()}, + (ctx, {id, steloTransferId}) => { + requireServicePrincipal(ctx); + const transfer = ctx.db.stelo_transfer.id.find(id); + if (transfer === null) throw new SenderError(CraftError.UNKNOWN_STELO_TRANSFER); + if (transfer.state.tag === 'settled') return; + if (transfer.state.tag === 'failed') throw invalidTransfer('already-failed'); + + const {payerAccountIdentity: payer, currency, amount, purpose} = transfer; + switch (purpose.tag) { + case 'hold': { + const {craftId, fundingLinkId, ratioNumerator, ratioDenominator, effortCovered} = purpose.value; + if (findOpenEscrow(ctx, craftId) !== null) throw new SenderError(CraftError.ESCROW_ALREADY_FUNDED); + ctx.db.craft_bounty_escrow.insert({ + id: 0n, craftId, payerAccountIdentity: payer, currency, fundingLinkId, fundingAccountId: transfer.fromAccountId, + heldAmount: amount, releasedAmount: 0n, ratioNumerator, ratioDenominator, + effortCovered, effortPriced: 0n, accruedNumerator: 0n, accruedDenominator: 1n, + heldAt: ctx.timestamp, updatedAt: ctx.timestamp, closedAt: undefined, + }); + journal(ctx, {payer, currency, kind: 'hold', amount, fundingAccountId: transfer.fromAccountId, craftId, transferId: id}); + break; + } + case 'topUp': { + const {craftId, ratioNumerator, ratioDenominator, reserved} = purpose.value; + const escrow = findOpenEscrow(ctx, craftId); + if (escrow === null) { + // The craft's hold closed while the pull was in flight: the money is simply owed back. + addRefundDue(ctx, payer, currency, transfer.fromAccountId, amount); + } else { + const wallet = findEscrowWallet(ctx, payer, currency, escrow.fundingAccountId); + if (reserved > 0n) { + if (wallet === null || wallet.refundDue < reserved) throw invalidTransfer('reserved-refund-due'); + ctx.db.bounty_escrow_wallet.id.update({...wallet, refundDue: wallet.refundDue - reserved, updatedAt: ctx.timestamp}); + } + ctx.db.craft_bounty_escrow.id.update({ + ...escrow, heldAmount: escrow.heldAmount + amount + reserved, ratioNumerator, ratioDenominator, updatedAt: ctx.timestamp, + }); + } + journal(ctx, {payer, currency, kind: 'topup', amount, fundingAccountId: transfer.fromAccountId, craftId, transferId: id}); + break; + } + case 'payout': { + const {payeePlayerId, fundingAccountId, extras} = purpose.value; + const pool = findPool(ctx, payer, payeePlayerId, currency, fundingAccountId); + if (pool === null) throw new SenderError(CraftError.UNKNOWN_ESCROW_POOL); + if (extras < 0n || extras > pool.pulledExtras) throw invalidTransfer('extras-exceed-pulled'); + const paid = { + ...pool, + paidEscrow: pool.paidEscrow + (amount - extras), + paidDirect: pool.paidDirect + extras, + pulledExtras: pool.pulledExtras - extras, + unclaimedSince: undefined, + updatedAt: ctx.timestamp, + }; + ctx.db.bounty_escrow_pool.id.update(paid); + releaseSurplusExtras(ctx, paid); + journal(ctx, {payer, currency, kind: 'payout', amount: -amount, fundingAccountId, payeePlayerId, transferId: id}); + addSurfacedPayment(ctx, payer, payeePlayerId, currency, amount, 'transfer'); + break; + } + case 'extras': { + const {payeePlayerId, fundingAccountId} = purpose.value; + const pool = findPool(ctx, payer, payeePlayerId, currency, fundingAccountId); + if (pool === null) throw new SenderError(CraftError.UNKNOWN_ESCROW_POOL); + const pulled = {...pool, pulledExtras: pool.pulledExtras + amount, updatedAt: ctx.timestamp}; + ctx.db.bounty_escrow_pool.id.update(pulled); + releaseSurplusExtras(ctx, pulled); + journal(ctx, {payer, currency, kind: 'extras', amount, fundingAccountId, payeePlayerId, transferId: id}); + break; + } + case 'refund': { + const {fundingAccountId} = purpose.value; + const wallet = findEscrowWallet(ctx, payer, currency, fundingAccountId); + if (wallet === null || wallet.refundDue < amount) throw invalidTransfer('refund-exceeds-due'); + ctx.db.bounty_escrow_wallet.id.update({...wallet, refundDue: wallet.refundDue - amount, updatedAt: ctx.timestamp}); + journal(ctx, {payer, currency, kind: 'refund', amount: -amount, fundingAccountId, transferId: id}); + break; + } + case 'direct': + break; + } + + ctx.db.stelo_transfer.id.update({ + ...transfer, state: {tag: 'settled'}, steloTransferId, settledAt: ctx.timestamp, + }); + } +); + +/** Service-principal-only: terminal failure of a pending intent (no money moved, so the books are untouched). */ +export const failSteloTransfer = spacetimedb.reducer( + {id: t.u64(), reason: t.string()}, + (ctx, {id, reason}) => { + requireServicePrincipal(ctx); + const transfer = ctx.db.stelo_transfer.id.find(id); + if (transfer === null) throw new SenderError(CraftError.UNKNOWN_STELO_TRANSFER); + if (transfer.state.tag !== 'pending') return; + ctx.db.stelo_transfer.id.update({ + ...transfer, state: {tag: 'failed'}, failureReason: reason.slice(0, 200), settledAt: ctx.timestamp, + }); + } +); + +// --------------------------------------------------------------------------- +// Escrow lifecycle +// --------------------------------------------------------------------------- + +/** + * Service-principal-only: the craft ended or its bounty was removed. Releases the unspent headroom + * (owed back to the funding wallet) and closes the open hold; accrued entitlement stays in its pool. + * No-op if the craft has no open hold. + */ +export const closeCraftEscrow = spacetimedb.reducer( + {craftId: t.u64()}, + (ctx, {craftId}) => { + requireServicePrincipal(ctx); + closeOpenEscrow(ctx, craftId); + } +); + +/** + * Service-principal-only: raises a craft's secured ratio using only refund due already sitting in + * escrow for its funding wallet, with no transfer. Fails unless that fully covers the raise; a + * partial cover goes through a `topUp` intent instead. + */ +export const raiseCraftEscrowFromRefundDue = spacetimedb.reducer( + {craftId: t.u64(), ratioNumerator: t.i64(), ratioDenominator: t.i64()}, + (ctx, {craftId, ratioNumerator, ratioDenominator}) => { + requireServicePrincipal(ctx); + validateRatio(ratioNumerator, ratioDenominator); + const escrow = findOpenEscrow(ctx, craftId); + if (escrow === null) throw new SenderError(CraftError.NO_OPEN_ESCROW); + const ratio = {numerator: ratioNumerator, denominator: ratioDenominator}; + if (ratioAtLeast(escrowRatio(escrow), ratio)) throw invalidTransfer('not-a-raise'); + + const need = escrowRequired(escrowAccrued(escrow), ratio, escrow.effortCovered, escrow.effortPriced) + - (escrow.heldAmount - escrow.releasedAmount); + if (need > 0n) { + const wallet = findEscrowWallet(ctx, escrow.payerAccountIdentity, escrow.currency, escrow.fundingAccountId); + if (wallet === null || need > reservableRefundDue(ctx, escrow.payerAccountIdentity, escrow.currency, escrow.fundingAccountId)) { + throw invalidTransfer('insufficient-refund-due'); + } + ctx.db.bounty_escrow_wallet.id.update({...wallet, refundDue: wallet.refundDue - need, updatedAt: ctx.timestamp}); + } + ctx.db.craft_bounty_escrow.id.update({ + ...escrow, + heldAmount: escrow.heldAmount + (need > 0n ? need : 0n), + ratioNumerator, ratioDenominator, + updatedAt: ctx.timestamp, + }); + } +); + +// --------------------------------------------------------------------------- +// Pool maintenance +// --------------------------------------------------------------------------- + +/** + * Service-principal-only: reclassifies units paid directly that have since become guaranteed, so the + * paying wallet gets them back from escrow. Recomputed here from the pool, never taken from the bot. + * Covers every paying wallet's pool for (payer, payee, currency). + */ +export const releaseEscrowOverlap = spacetimedb.reducer( + {payerAccountIdentity: t.identity(), payeePlayerId: t.u64(), currency: t.string()}, + (ctx, {payerAccountIdentity, payeePlayerId, currency}) => { + requireServicePrincipal(ctx); + const pools = [...ctx.db.bounty_escrow_pool.by_payer_payee.filter([payerAccountIdentity, payeePlayerId])] + .filter(pool => pool.currency === currency); + for (const pool of pools) { + const r = overlapRelease(pool); + if (r <= 0n) continue; + const reclassified = {...pool, paidEscrow: pool.paidEscrow + r, paidDirect: pool.paidDirect - r, updatedAt: ctx.timestamp}; + ctx.db.bounty_escrow_pool.id.update(reclassified); + addRefundDue(ctx, payerAccountIdentity, currency, pool.fundingAccountId, r); + releaseSurplusExtras(ctx, reclassified); + } + } +); + +/** + * Service-principal-only: sets `unclaimedSince` on a pool that is owed money while its payee has no + * routable wallet, and clears it once they do. Routability is recomputed here. + */ +export const syncEscrowPoolUnclaimed = spacetimedb.reducer( + {payerAccountIdentity: t.identity(), payeePlayerId: t.u64(), currency: t.string(), fundingAccountId: t.u64()}, + (ctx, {payerAccountIdentity, payeePlayerId, currency, fundingAccountId}) => { + requireServicePrincipal(ctx); + const pool = findPool(ctx, payerAccountIdentity, payeePlayerId, currency, fundingAccountId); + if (pool === null) throw new SenderError(CraftError.UNKNOWN_ESCROW_POOL); + const unclaimed = splitPayout(pool).owed > 0n && routePayeeWallet(ctx, payeePlayerId, currency) === null; + if (unclaimed && pool.unclaimedSince === undefined) { + ctx.db.bounty_escrow_pool.id.update({...pool, unclaimedSince: ctx.timestamp}); + } else if (!unclaimed && pool.unclaimedSince !== undefined) { + ctx.db.bounty_escrow_pool.id.update({...pool, unclaimedSince: undefined}); + } + } +); + +/** + * Service-principal-only: closes out everything a pool still owes an unroutable payee. The + * escrow-backed part and any loyalty extras already pulled into escrow go back to the paying wallet's + * refund due; extras not yet pulled need no movement. `rollTo` credits the owed amount to the payee as that + * (manually recorded) currency's entitlement; absent means refund only. The age policy + * (`afterDays`) is `brico-bot` configuration, so only the unclaimed state is checked here. + */ +export const settleUnclaimedPayee = spacetimedb.reducer( + { + payerAccountIdentity: t.identity(), + payeePlayerId: t.u64(), + currency: t.string(), + fundingAccountId: t.u64(), + rollTo: t.option(t.string()), + }, + (ctx, {payerAccountIdentity, payeePlayerId, currency, fundingAccountId, rollTo}) => { + requireServicePrincipal(ctx); + if (rollTo !== undefined) validateManualBountyCurrency(rollTo); + const pool = findPool(ctx, payerAccountIdentity, payeePlayerId, currency, fundingAccountId); + if (pool === null) throw new SenderError(CraftError.UNKNOWN_ESCROW_POOL); + if (pool.unclaimedSince === undefined || routePayeeWallet(ctx, payeePlayerId, currency) !== null) { + throw new SenderError(CraftError.PAYEE_NOT_UNCLAIMED); + } + if (pendingTransfers(ctx, payerAccountIdentity).some(row => (row.purpose.tag === 'payout' || row.purpose.tag === 'extras') + && row.purpose.value.payeePlayerId === payeePlayerId + && row.purpose.value.fundingAccountId === fundingAccountId)) { + throw new SenderError(CraftError.PAYOUT_ALREADY_PENDING); + } + const {owed, fromEscrow, fromWallet} = splitPayout(pool); + if (owed <= 0n) throw new SenderError(CraftError.NOTHING_OWED); + + ctx.db.bounty_escrow_pool.id.update({ + ...pool, + paidEscrow: pool.paidEscrow + fromEscrow, + paidDirect: pool.paidDirect + fromWallet, + pulledExtras: 0n, + unclaimedSince: undefined, + updatedAt: ctx.timestamp, + }); + // Extras pulled into escrow but never paid out go back with the unspent guaranteed part. + addRefundDue(ctx, payerAccountIdentity, currency, fundingAccountId, fromEscrow + pool.pulledExtras); + addSurfacedPayment(ctx, payerAccountIdentity, payeePlayerId, currency, owed, rollTo === undefined ? 'forfeit' : 'rollover'); + if (rollTo !== undefined) addSurfacedTotal(ctx, payerAccountIdentity, payeePlayerId, rollTo, owed, 0n, ctx.timestamp); + } +); + +/** Service-principal-only: how often the payout sweep runs and when it last did (a singleton row, so restarts keep the cadence). */ +export const setSteloPayoutSchedule = spacetimedb.reducer( + {intervalMicros: t.i64(), lastSweepAt: t.timestamp()}, + (ctx, {intervalMicros, lastSweepAt}) => { + requireServicePrincipal(ctx); + if (intervalMicros <= 0n) throw invalidTransfer('interval'); + const row = {id: 0, intervalMicros, lastSweepAt}; + if (ctx.db.stelo_payout_schedule.id.find(0) === null) ctx.db.stelo_payout_schedule.insert(row); + else ctx.db.stelo_payout_schedule.id.update(row); + } +); + +/** + * Service-principal-only: publishes what the bot does with an escrowed currency's unclaimed money. + * A rollover must name a manually recorded currency; a refund names none. No-op when unchanged. + */ +export const setSteloCurrencyPolicy = spacetimedb.reducer( + {currency: t.string(), action: t.string(), rollTo: t.option(t.string()), afterDays: t.u32()}, + (ctx, {currency, action, rollTo, afterDays}) => { + requireServicePrincipal(ctx); + requireEscrowedCurrency(currency); + if (action !== 'rollover' && action !== 'refund') throw invalidTransfer('policy-action'); + if (action === 'rollover') { + if (rollTo === undefined) throw invalidTransfer('policy-roll-to'); + validateManualBountyCurrency(rollTo); + } else if (rollTo !== undefined) { + throw invalidTransfer('policy-roll-to'); + } + const existing = ctx.db.stelo_currency_policy.currency.find(currency); + if (existing === null) { + ctx.db.stelo_currency_policy.insert({currency, action, rollTo, afterDays}); + } else if (existing.action !== action || existing.rollTo !== rollTo || existing.afterDays !== afterDays) { + ctx.db.stelo_currency_policy.currency.update({currency, action, rollTo, afterDays}); + } + } +); + +// --------------------------------------------------------------------------- +// Stelo wallets +// --------------------------------------------------------------------------- + +function requireActiveSteloLink(ctx: Ctx, linkId: bigint, accountIdentity?: Identity) { + const link = ctx.db.linked_integration.id.find(linkId); + if (link === null || link.provider !== 'stelo' || link.revokedAt !== undefined + || (accountIdentity !== undefined && !link.accountIdentity.isEqual(accountIdentity))) { + throw new SenderError(CraftError.NOT_AN_ACTIVE_STELO_LINK); + } + return link; +} + +/** + * Service-principal-only: records which escrow currency's ledger a freshly verified `stelo` link is + * on. The account's first wallet on a currency also becomes its receiving wallet. + */ +export const registerSteloWallet = spacetimedb.reducer( + {linkId: t.u64(), currency: t.string()}, + (ctx, {linkId, currency}) => { + requireServicePrincipal(ctx); + requireEscrowedCurrency(currency); + const link = requireActiveSteloLink(ctx, linkId); + const existing = ctx.db.stelo_wallet.linkId.find(linkId); + if (existing === null) { + ctx.db.stelo_wallet.insert({linkId, accountIdentity: link.accountIdentity, currency}); + } else { + ctx.db.stelo_wallet.linkId.update({...existing, currency}); + } + const hasReceiving = [...ctx.db.stelo_receiving_wallet.by_account_currency.filter([link.accountIdentity, currency])].length > 0; + if (!hasReceiving) { + ctx.db.stelo_receiving_wallet.insert({id: 0n, accountIdentity: link.accountIdentity, currency, linkId}); + } + } +); + +/** Designates one of the caller's own registered wallets as where payouts and refunds in `currency` go. */ +export const setSteloReceivingWallet = spacetimedb.reducer( + {currency: t.string(), linkId: t.u64()}, + (ctx, {currency, linkId}) => { + requireAccount(ctx); + requireEscrowedCurrency(currency); + requireActiveSteloLink(ctx, linkId, ctx.sender); + if (ctx.db.stelo_wallet.linkId.find(linkId)?.currency !== currency) throw new SenderError(CraftError.INVALID_FUNDING_WALLET); + + const existing = [...ctx.db.stelo_receiving_wallet.by_account_currency.filter([ctx.sender, currency])][0]; + if (existing === undefined) { + ctx.db.stelo_receiving_wallet.insert({id: 0n, accountIdentity: ctx.sender, currency, linkId}); + } else { + ctx.db.stelo_receiving_wallet.id.update({...existing, linkId}); + } + } +); + +/** Service-principal-only: records whether `brico-bot` can pull from a registered wallet and whether its linker is verified. Writes only when a value changes. */ +export const setSteloWalletStatus = spacetimedb.reducer( + {linkId: t.u64(), canPull: t.bool(), verified: t.bool()}, + (ctx, {linkId, canPull, verified}) => { + requireServicePrincipal(ctx); + const wallet = ctx.db.stelo_wallet.linkId.find(linkId); + if (wallet === null) throw new SenderError(CraftError.NOT_AN_ACTIVE_STELO_LINK); + const existing = ctx.db.stelo_wallet_status.linkId.find(linkId); + if (existing === null) { + ctx.db.stelo_wallet_status.insert({linkId, accountIdentity: wallet.accountIdentity, canPull, verified, updatedAt: ctx.timestamp}); + } else if (existing.canPull !== canPull || existing.verified !== verified) { + ctx.db.stelo_wallet_status.linkId.update({...existing, canPull, verified, updatedAt: ctx.timestamp}); + } + } +); + +// --------------------------------------------------------------------------- +// Funding status +// --------------------------------------------------------------------------- + +const FUNDING_STATE_SET: ReadonlySet = new Set(FUNDING_STATES); +const FUNDING_SOURCE_SET: ReadonlySet = new Set(FUNDING_SOURCES); +const FUNDING_REASON_SET: ReadonlySet = new Set(FUNDING_REASONS); + +function invalidFunding(field: string): SenderError { + return new SenderError(`${CraftError.INVALID_FUNDING_STATUS}:${field}`); +} + +/** + * Service-principal-only: upserts why a craft's escrowed bounty is or isn't funded. A no-op when + * nothing but `updatedAt` would change. `state`, `source` and `reason` are checked against the + * `@brico/crafts/stelo` allow-lists. + */ +export const setCraftBountyFunding = spacetimedb.reducer( + { + craftId: t.u64(), + payerAccountIdentity: t.identity(), + source: t.string(), + ruleId: t.option(t.string()), + currency: t.string(), + fundingLinkId: t.option(t.u64()), + ratioNumerator: t.i64(), + ratioDenominator: t.i64(), + state: t.string(), + reason: t.option(t.string()), + transferId: t.option(t.u64()), + }, + (ctx, args) => { + requireServicePrincipal(ctx); + requireEscrowedCurrency(args.currency); + if (!FUNDING_STATE_SET.has(args.state)) throw invalidFunding('state'); + if (!FUNDING_SOURCE_SET.has(args.source)) throw invalidFunding('source'); + if (args.reason !== undefined && !FUNDING_REASON_SET.has(args.reason)) throw invalidFunding('reason'); + validateRatio(args.ratioNumerator, args.ratioDenominator); + if (ctx.db.account.identity.find(args.payerAccountIdentity) === null) throw new SenderError(CraftError.UNKNOWN_BRICO_ACCOUNT); + + const {craftId} = args; + const fields = { + payerAccountIdentity: args.payerAccountIdentity, + source: args.source, + ruleId: args.ruleId, + currency: args.currency, + fundingLinkId: args.fundingLinkId, + ratioNumerator: args.ratioNumerator, + ratioDenominator: args.ratioDenominator, + state: args.state, + reason: args.reason, + transferId: args.transferId, + }; + const existing = ctx.db.craft_bounty_funding.craftId.find(craftId); + if (existing === null) { + ctx.db.craft_bounty_funding.insert({craftId, ...fields, updatedAt: ctx.timestamp}); + return; + } + const same = existing.payerAccountIdentity.isEqual(fields.payerAccountIdentity) + && existing.source === fields.source && existing.ruleId === fields.ruleId && existing.currency === fields.currency + && existing.fundingLinkId === fields.fundingLinkId && existing.ratioNumerator === fields.ratioNumerator + && existing.ratioDenominator === fields.ratioDenominator && existing.state === fields.state + && existing.reason === fields.reason && existing.transferId === fields.transferId; + if (!same) ctx.db.craft_bounty_funding.craftId.update({craftId, ...fields, updatedAt: ctx.timestamp}); + } +); + +/** Service-principal-only: drops a craft's funding row once it has no escrowed-currency intent any more. No-op if absent. */ +export const clearCraftBountyFunding = spacetimedb.reducer( + {craftId: t.u64()}, + (ctx, {craftId}) => { + requireServicePrincipal(ctx); + ctx.db.craft_bounty_funding.craftId.delete(craftId); + } +); diff --git a/backend/brico-module/spacetimedb/src/reducers/integrations.ts b/backend/brico-module/spacetimedb/src/reducers/integrations.ts index da4ef462..a7f9964c 100644 --- a/backend/brico-module/spacetimedb/src/reducers/integrations.ts +++ b/backend/brico-module/spacetimedb/src/reducers/integrations.ts @@ -1,17 +1,23 @@ // noinspection JSUnusedGlobalSymbols import {CraftError} from '@brico/crafts/errors'; +import {normalizeSteloAddress} from '@brico/crafts/stelo'; import type {Identity} from 'spacetimedb'; import {ScheduleAt} from 'spacetimedb'; import {SenderError, t} from 'spacetimedb/server'; import {requireAccount, requireServicePrincipal, requireTrustedJwt} from '../lib/auth'; +import {removeSteloWallet} from '../lib/escrow'; import type {Ctx} from '../schema'; import {spacetimedb} from '../schema'; import {integration_link_request} from '../tables/integrations'; +/** Providers whose external id may be linked by several brico accounts at once (a Stelo wallet shared by a guild). */ +const SHARED_EXTERNAL_PROVIDERS: ReadonlySet = new Set(['stelo']); + /** * Shared body of `linkIntegration` and `tryFinalizeIntegrationLink` below. Enforces "at most one - * active brico account per (provider, externalId)". + * active brico account per (provider, externalId)" except for `SHARED_EXTERNAL_PROVIDERS`, where + * only the same account linking twice is a no-op. */ function linkIntegrationInternal( ctx: Ctx, @@ -25,19 +31,26 @@ function linkIntegrationInternal( throw new SenderError(CraftError.UNKNOWN_BRICO_ACCOUNT); } + if (!externalHandle?.trim()) externalHandle = undefined; + for (const row of ctx.db.linked_integration.by_provider_external_id.filter([ provider, externalId, ])) { if (row.revokedAt !== undefined) continue; - // Re-linking the same account to its own already-active link is a no-op (keeps `linkedAt`); - // any other account holding an active link on this external id is the attribution hole above. - if (row.accountIdentity.isEqual(accountIdentity)) return; + // Re-linking the same account to its own already-active link keeps `linkedAt` and only + // refreshes a renamed handle; any other account holding an active link on this external id + // is the attribution hole above. + if (row.accountIdentity.isEqual(accountIdentity)) { + if (externalHandle !== undefined && externalHandle !== row.externalHandle) { + ctx.db.linked_integration.id.update({...row, externalHandle}); + } + return; + } + if (SHARED_EXTERNAL_PROVIDERS.has(provider)) continue; throw new SenderError(CraftError.EXTERNAL_ACCOUNT_ALREADY_LINKED); } - if (!externalHandle?.trim()) externalHandle = undefined; - ctx.db.linked_integration.insert({ id: 0n, accountIdentity, @@ -83,6 +96,7 @@ export const unlinkIntegration = spacetimedb.reducer( if (!row.accountIdentity.isEqual(ctx.sender)) requireServicePrincipal(ctx); if (row.revokedAt !== undefined) return; ctx.db.linked_integration.id.update({...row, revokedAt: ctx.timestamp}); + if (row.provider === 'stelo') removeSteloWallet(ctx, id); } ); @@ -153,6 +167,11 @@ export const beginIntegrationLink = spacetimedb.reducer( externalId: undefined, externalHandle: undefined, createdAt: ctx.timestamp, + candidateAddress: undefined, + pendingReason: undefined, + unverified: false, + matchedUsername: undefined, + confirmed: false, }); } ); @@ -183,6 +202,11 @@ export const beginIntegrationLinkForExternal = spacetimedb.reducer( externalId, externalHandle, createdAt: ctx.timestamp, + candidateAddress: undefined, + pendingReason: undefined, + unverified: false, + matchedUsername: undefined, + confirmed: false, }); } ); @@ -211,10 +235,88 @@ export const noteIntegrationLinkExternal = spacetimedb.reducer( if (request.externalId !== undefined) { throw new SenderError(CraftError.LINK_CODE_ALREADY_RESOLVED); } + // A `stelo` link only ever finalizes on the wallet the browser submitted, so the bot can't be + // steered (or a browser skip the bot's verification) onto a different account. + if (request.provider === 'stelo' && request.candidateAddress !== externalHandle) { + throw new SenderError(CraftError.LINK_CANDIDATE_MISMATCH); + } + // A match the bot is waiting on the browser to confirm cannot finalize before it does. + if (request.provider === 'stelo' && request.pendingReason === 'confirm-unverified' && !request.confirmed) { + throw new SenderError(CraftError.LINK_NOT_CONFIRMED); + } tryFinalizeIntegrationLink(ctx, {...request, externalId, externalHandle}); } ); +/** + * Browser side of a `stelo` link: names the Stelo account address (`#ABCD`, normalized here) the caller + * says they control. Writes only `candidateAddress`, never `externalId`, so nothing finalizes until + * `brico-bot` resolves and verifies it and calls `noteIntegrationLinkExternal`. `unverified` lets the + * bot link an account it cannot verify ownership of, after the caller confirms the match it found; + * such a wallet can receive payouts but never fund bounties. + */ +export const submitIntegrationLinkCandidate = spacetimedb.reducer( + {code: t.string(), candidate: t.string(), unverified: t.bool()}, + (ctx, {code, candidate, unverified}) => { + requireAccount(ctx); + const request = ctx.db.integration_link_request.code.find(code); + if (request === null) throw new SenderError(CraftError.LINK_CODE_UNKNOWN_OR_EXPIRED); + const address = normalizeSteloAddress(candidate); + if (request.provider !== 'stelo' || request.accountIdentity === undefined || !request.accountIdentity.isEqual(ctx.sender) + || request.externalId !== undefined || address === null) { + throw new SenderError(CraftError.INVALID_LINK_CANDIDATE); + } + ctx.db.integration_link_request.id.update({ + ...request, candidateAddress: address, pendingReason: undefined, unverified, matchedUsername: undefined, confirmed: false, + }); + } +); + +/** Browser side of an unverified `stelo` link: accepts the account the bot matched, which lets the bot finalize. */ +export const confirmIntegrationLinkCandidate = spacetimedb.reducer( + {code: t.string()}, + (ctx, {code}) => { + requireAccount(ctx); + const request = ctx.db.integration_link_request.code.find(code); + if (request === null) throw new SenderError(CraftError.LINK_CODE_UNKNOWN_OR_EXPIRED); + if (request.provider !== 'stelo' || request.accountIdentity === undefined || !request.accountIdentity.isEqual(ctx.sender) + || request.externalId !== undefined || !request.unverified || request.pendingReason !== 'confirm-unverified') { + throw new SenderError(CraftError.INVALID_LINK_CANDIDATE); + } + ctx.db.integration_link_request.id.update({...request, confirmed: true}); + } +); + +/** Deletes the caller's own unresolved link request so the browser can abandon a waiting one. No-op if it is gone or already resolved. */ +export const cancelIntegrationLinkRequest = spacetimedb.reducer( + {code: t.string()}, + (ctx, {code}) => { + requireAccount(ctx); + const request = ctx.db.integration_link_request.code.find(code); + if (request === null) return; + if (request.accountIdentity === undefined || !request.accountIdentity.isEqual(ctx.sender)) { + throw new SenderError(CraftError.LINK_CODE_UNKNOWN_OR_EXPIRED); + } + if (request.externalId !== undefined) return; + ctx.db.integration_link_request.id.delete(request.id); + } +); + +/** + * Service-principal-only: records why a `stelo` request isn't verified yet, and for an unverified + * link the matched account's username. Writes only when something changes. + */ +export const noteIntegrationLinkStatus = spacetimedb.reducer( + {code: t.string(), reason: t.string(), matchedUsername: t.option(t.string())}, + (ctx, {code, reason, matchedUsername}) => { + requireServicePrincipal(ctx); + const request = ctx.db.integration_link_request.code.find(code); + if (request === null) throw new SenderError(CraftError.LINK_CODE_UNKNOWN_OR_EXPIRED); + if (request.pendingReason === reason && request.matchedUsername === matchedUsername) return; + ctx.db.integration_link_request.id.update({...request, pendingReason: reason, matchedUsername}); + } +); + /** * Discord-login shortcut: when a user's SpacetimeAuth login method is Discord, the ID token that * authenticated this very connection already carries the Discord snowflake (`provider_id`) and diff --git a/backend/brico-module/spacetimedb/src/reducers/notifications.ts b/backend/brico-module/spacetimedb/src/reducers/notifications.ts index f002201e..f808ff24 100644 --- a/backend/brico-module/spacetimedb/src/reducers/notifications.ts +++ b/backend/brico-module/spacetimedb/src/reducers/notifications.ts @@ -1,10 +1,23 @@ // noinspection JSUnusedGlobalSymbols import {CraftError} from '@brico/crafts/errors'; +import {encodeNotificationPayload} from '@brico/crafts/notification'; +import {FUNDING_NOTIFICATION_KINDS, FUNDING_NOTIFICATION_ROLES, FUNDING_REASONS} from '@brico/crafts/stelo'; +import type {Infer} from 'spacetimedb/server'; import {SenderError, t} from 'spacetimedb/server'; import {requireAccount, requireServicePrincipal} from '../lib/auth'; +import {isEscrowedCurrency} from '../lib/escrow'; import {spacetimedb} from '../schema'; -import {CraftNotificationPayload} from '../tables/notifications'; +import {CraftNotificationPayload, FundingNotificationPayload} from '../tables/notifications'; + +const craftJson = (value: Infer) => + encodeNotificationPayload({tag: 'craft', value}); + +const fundingJson = (value: Infer) => + encodeNotificationPayload({ + tag: 'funding', + value: {...value, craftId: value.craftId?.toString(), amount: value.amount?.toString()}, + }); // --------------------------------------------------------------------------- // Generic notifications, craft-sourced @@ -13,9 +26,8 @@ import {CraftNotificationPayload} from '../tables/notifications'; const CRAFT_NOTIFICATION_EVENT_KINDS = new Set(['added', 'finished', 'removed']); /** - * Written by `brico-bot` once a watch fires (`notification-sink.ts`). Kept as the one - * craft-specific entry point; a future watchable type gets its own `postXNotification` - * reducer, all inserting into this same `notification` table. + * Written by `brico-bot` once a watch fires (`notification-sink.ts`). Each notification source + * has its own `postXNotification` reducer, all inserting into `notification_v2`. */ export const postCraftNotification = spacetimedb.reducer( {accountIdentity: t.identity(), payload: CraftNotificationPayload}, @@ -28,10 +40,10 @@ export const postCraftNotification = spacetimedb.reducer( throw new SenderError(CraftError.UNKNOWN_BRICO_ACCOUNT); } - ctx.db.notification.insert({ + ctx.db.notification_v2.insert({ id: 0n, accountIdentity, - payload: {tag: 'craft', value: payload}, + payloadJson: craftJson(payload), createdAt: ctx.timestamp, readAt: undefined, }); @@ -42,20 +54,20 @@ export const postCraftNotification = spacetimedb.reducer( export const markNotificationRead = spacetimedb.reducer( {id: t.u64()}, (ctx, {id}) => { - const row = ctx.db.notification.id.find(id); + const row = ctx.db.notification_v2.id.find(id); if (row === null) return; if (!row.accountIdentity.isEqual(ctx.sender)) requireServicePrincipal(ctx); if (row.readAt !== undefined) return; - ctx.db.notification.id.update({...row, readAt: ctx.timestamp}); + ctx.db.notification_v2.id.update({...row, readAt: ctx.timestamp}); } ); /** Marks every one of the caller's own notifications read. */ export const markAllNotificationsRead = spacetimedb.reducer(ctx => { requireAccount(ctx); - for (const row of ctx.db.notification.accountIdentity.filter(ctx.sender)) { + for (const row of ctx.db.notification_v2.accountIdentity.filter(ctx.sender)) { if (row.readAt === undefined) { - ctx.db.notification.id.update({...row, readAt: ctx.timestamp}); + ctx.db.notification_v2.id.update({...row, readAt: ctx.timestamp}); } } }); @@ -65,10 +77,60 @@ export const deleteNotification = spacetimedb.reducer( {ids: t.array(t.u64())}, (ctx, {ids}) => { for (const id of ids) { - const row = ctx.db.notification.id.find(id); + const row = ctx.db.notification_v2.id.find(id); if (row === null) continue; if (!row.accountIdentity.isEqual(ctx.sender)) requireServicePrincipal(ctx); - ctx.db.notification.id.delete(id); + ctx.db.notification_v2.id.delete(id); + } + } +); + +/** + * Moves every legacy `notification` row into `notification_v2` (JSON payload, same timestamps and + * read state) and deletes it. Idempotent; a no-op once the legacy table is empty. + */ +export const migrateNotificationsToV2 = spacetimedb.reducer(ctx => { + requireServicePrincipal(ctx); + for (const row of ctx.db.notification.iter()) { + ctx.db.notification_v2.insert({ + id: 0n, + accountIdentity: row.accountIdentity, + payloadJson: craftJson(row.payload.value), + createdAt: row.createdAt, + readAt: row.readAt, + }); + ctx.db.notification.id.delete(row.id); + } +}); + +const FUNDING_KIND_SET: ReadonlySet = new Set(FUNDING_NOTIFICATION_KINDS); +const FUNDING_ROLE_SET: ReadonlySet = new Set(FUNDING_NOTIFICATION_ROLES); +const FUNDING_REASON_SET: ReadonlySet = new Set(FUNDING_REASONS); +const MAX_PARTY_NAME_LENGTH = 100; + +/** + * Written by `brico-bot` for a funding alert (`stelo/notify.ts`). `kind`, `role`, `currency` and + * `reason` are checked against their allow-lists, so a payload carries no wording; the only display + * text is the two party names, length-capped. + */ +export const postFundingNotification = spacetimedb.reducer( + {accountIdentity: t.identity(), payload: FundingNotificationPayload}, + (ctx, {accountIdentity, payload}) => { + requireServicePrincipal(ctx); + if (!FUNDING_KIND_SET.has(payload.kind)) throw new SenderError(`${CraftError.UNKNOWN_EVENT_KIND}:${payload.kind}`); + if (!FUNDING_ROLE_SET.has(payload.role) || !isEscrowedCurrency(payload.currency) + || (payload.reason !== undefined && !FUNDING_REASON_SET.has(payload.reason)) + || (payload.payerName ?? '').length > MAX_PARTY_NAME_LENGTH || (payload.payeeName ?? '').length > MAX_PARTY_NAME_LENGTH) { + throw new SenderError(`${CraftError.INVALID_FUNDING_STATUS}:payload`); } + if (ctx.db.account.identity.find(accountIdentity) === null) throw new SenderError(CraftError.UNKNOWN_BRICO_ACCOUNT); + + ctx.db.notification_v2.insert({ + id: 0n, + accountIdentity, + payloadJson: fundingJson(payload), + createdAt: ctx.timestamp, + readAt: undefined, + }); } ); diff --git a/backend/brico-module/spacetimedb/src/reducers/notify.ts b/backend/brico-module/spacetimedb/src/reducers/notify.ts index 388a2d3b..800b887e 100644 --- a/backend/brico-module/spacetimedb/src/reducers/notify.ts +++ b/backend/brico-module/spacetimedb/src/reducers/notify.ts @@ -3,6 +3,7 @@ import {CraftError} from '@brico/crafts/errors'; import {SenderError, t} from 'spacetimedb/server'; import {requireAccount} from '../lib/auth'; +import {findFundingTrigger, requireOwnDiscordSink} from '../lib/notify'; import {clampClientTimestamp, isAtLeastAsNew} from '../lib/sync'; import {spacetimedb} from '../schema'; import {NotifySinkRef} from '../tables/notify'; @@ -89,3 +90,53 @@ export const detachCraftFilterNotifyTrigger = spacetimedb.reducer( ctx.db.craft_filter_notify_trigger.id.update({...existing, updatedAt: clampedDeletedAt, deletedAt: clampedDeletedAt}); } ); + +/** + * Creates or edits the caller's funding-alert trigger for one sink (toast or one of their Discord + * sinks). Existing rows are found by scanning the caller's rows, as for `upsertCraftFilterNotifyTrigger`. + */ +export const upsertFundingNotifyTrigger = spacetimedb.reducer( + { + id: t.string(), + sink: NotifySinkRef, + fundingFailed: t.bool(), + payoutShortfall: t.bool(), + unclaimed: t.bool(), + updatedAt: t.timestamp(), + }, + (ctx, {id, sink, fundingFailed, payoutShortfall, unclaimed, updatedAt}) => { + requireAccount(ctx); + requireOwnDiscordSink(ctx, ctx.sender, sink); + + const existing = findFundingTrigger(ctx, ctx.sender, sink); + const clampedUpdatedAt = clampClientTimestamp(ctx, updatedAt); + if (existing !== null && !isAtLeastAsNew(clampedUpdatedAt, existing.updatedAt)) return; + + if (existing === null) { + ctx.db.funding_notify_trigger.insert({ + id, accountIdentity: ctx.sender, sink, fundingFailed, payoutShortfall, unclaimed, + updatedAt: clampedUpdatedAt, deletedAt: undefined, + }); + } else { + ctx.db.funding_notify_trigger.id.update({ + ...existing, fundingFailed, payoutShortfall, unclaimed, updatedAt: clampedUpdatedAt, deletedAt: undefined, + }); + } + } +); + +/** Tombstones the caller's own funding-alert trigger (no hard delete). Silent no-op if already gone. */ +export const detachFundingNotifyTrigger = spacetimedb.reducer( + {id: t.string(), deletedAt: t.timestamp()}, + (ctx, {id, deletedAt}) => { + requireAccount(ctx); + const existing = ctx.db.funding_notify_trigger.id.find(id); + if (existing === null) return; + if (!existing.accountIdentity.isEqual(ctx.sender)) { + throw new SenderError(CraftError.FUNDING_NOTIFY_TRIGGER_BELONGS_TO_ANOTHER_ACCOUNT); + } + const clampedDeletedAt = clampClientTimestamp(ctx, deletedAt); + if (!isAtLeastAsNew(clampedDeletedAt, existing.updatedAt)) return; + ctx.db.funding_notify_trigger.id.update({...existing, updatedAt: clampedDeletedAt, deletedAt: clampedDeletedAt}); + } +); diff --git a/backend/brico-module/spacetimedb/src/reducers/payouts.ts b/backend/brico-module/spacetimedb/src/reducers/payouts.ts index 7c040b12..fd4e6d9a 100644 --- a/backend/brico-module/spacetimedb/src/reducers/payouts.ts +++ b/backend/brico-module/spacetimedb/src/reducers/payouts.ts @@ -1,12 +1,13 @@ // noinspection JSUnusedGlobalSymbols -import {advancePool, reduceRatio} from '@brico/crafts/entitlement'; +import {addRatio, advancePool, assertFitsI64, fitFraction, type Fraction, reduceRatio} from '@brico/crafts/entitlement'; import {CraftError} from '@brico/crafts/errors'; import {CLAIM_ACCESS_FLAGS} from '@brico/crafts/filter'; import type {Identity, Timestamp} from 'spacetimedb'; import {SenderError, t} from 'spacetimedb/server'; import {requireAccount, requireServicePrincipal} from '../lib/auth'; import {validateBountyCurrency, validateManualBountyCurrency, validateRatio} from '../lib/bounty'; +import {addSurfacedTotal, escrowAccrued, escrowRatio, findOpenEscrow, findPool, isEscrowedCurrency, poolFractions, ratioAtLeast} from '../lib/escrow'; import {clampClientTimestamp, isAtLeastAsNew} from '../lib/sync'; import type {Ctx} from '../schema'; import {spacetimedb} from '../schema'; @@ -199,7 +200,8 @@ export const deleteLoyaltyBonusTotal = spacetimedb.reducer( * fraction forward — so a fractional remainder is pooled across every craft, not stranded in * whichever craft happened to produce it. Looked up via `by_payer_payee` and filtered to `currency` * in JS rather than a dedicated 3-column index, since uniqueness on the triple is enforced here, - * not by the database (see the table's doc comment). + * not by the database (see the table's doc comment). Used for non-escrowed currencies; an escrowed + * currency's carry lives in per-wallet `bounty_escrow_pool` rows instead (`addToEscrowPools`). */ function addToEntitlementTotal( ctx: Ctx, @@ -234,6 +236,49 @@ function addToEntitlementTotal( } } +/** + * Folds one delta into the escrow pool backing `escrow` for (payer, payee, currency): `deltaFull` + * into the full sum and `deltaGuaranteed` into the guaranteed sum, each floored with its own pooled + * carry. The surfaced `bounty_entitlement_total.total` grows by the full sum's newly floored units. + */ +function addToEscrowPools( + ctx: Ctx, + escrow: NonNullable>, + payeePlayerId: bigint, + deltaFull: Fraction, + deltaGuaranteed: Fraction, + deltaEffort: bigint, + updatedAt: Timestamp, +): void { + const payer = escrow.payerAccountIdentity; + const pool = findPool(ctx, payer, payeePlayerId, escrow.currency, escrow.fundingAccountId); + const carry = pool === null + ? {full: {numerator: 0n, denominator: 1n}, guaranteed: {numerator: 0n, denominator: 1n}} + : poolFractions(pool); + const full = advancePool(carry.full, deltaFull); + const guaranteed = advancePool(carry.guaranteed, deltaGuaranteed); + + if (pool === null) { + ctx.db.bounty_escrow_pool.insert({ + id: 0n, payerAccountIdentity: payer, payeePlayerId, currency: escrow.currency, fundingAccountId: escrow.fundingAccountId, + total: full.earned, remainderNumerator: full.remainder.numerator, remainderDenominator: full.remainder.denominator, + guaranteedTotal: guaranteed.earned, + guaranteedRemainderNumerator: guaranteed.remainder.numerator, guaranteedRemainderDenominator: guaranteed.remainder.denominator, + paidEscrow: 0n, paidDirect: 0n, pulledExtras: 0n, unclaimedSince: undefined, updatedAt, + }); + } else { + ctx.db.bounty_escrow_pool.id.update({ + ...pool, + total: pool.total + full.earned, + remainderNumerator: full.remainder.numerator, remainderDenominator: full.remainder.denominator, + guaranteedTotal: pool.guaranteedTotal + guaranteed.earned, + guaranteedRemainderNumerator: guaranteed.remainder.numerator, guaranteedRemainderDenominator: guaranteed.remainder.denominator, + updatedAt, + }); + } + addSurfacedTotal(ctx, payer, payeePlayerId, escrow.currency, full.earned, deltaEffort, updatedAt); +} + /** * A craft's resolved bounty assignment, checking both the public and private assignment tables — * same "either one, whichever has the row" lookup `assignCraftBounty`/`clearCraftBounty` already do, @@ -274,6 +319,13 @@ function findCraftBountyAssignment(ctx: Ctx, craftId: bigint) { * a different payer mid-craft, effort already recorded under the old payer stays theirs, and only new * effort from here on accrues to whoever pays now. That craft must have an assignment, since the bot * only ever calls this for a craft that has one. + * + * `ratioNumerator`/`ratioDenominator` is the *effective* ratio (base plus loyalty) and + * `baseRatio*` the base alone, both from the bot's same snapshot; the effective ratio must be at + * least the base. For an escrowed currency the call prices the delta twice into the pool of the + * craft's funding wallet: in full at the effective ratio, and as the guaranteed part at + * `min(base, escrow ratio)`, clamped so the craft's accrual never exceeds what its hold still holds. + * A craft with no open hold prices nothing. */ export const upsertCraftBountyEntitlement = spacetimedb.reducer( { @@ -283,15 +335,24 @@ export const upsertCraftBountyEntitlement = spacetimedb.reducer( effort: t.i64(), ratioNumerator: t.i64(), ratioDenominator: t.i64(), + baseRatioNumerator: t.i64(), + baseRatioDenominator: t.i64(), updatedAt: t.timestamp(), }, - (ctx, {craftId, playerId, currency, effort, ratioNumerator, ratioDenominator, updatedAt}) => { + (ctx, {craftId, playerId, currency, effort, ratioNumerator, ratioDenominator, baseRatioNumerator, baseRatioDenominator, updatedAt}) => { requireServicePrincipal(ctx); validateRatio(ratioNumerator, ratioDenominator); + validateRatio(baseRatioNumerator, baseRatioDenominator); + const effective = {numerator: ratioNumerator, denominator: ratioDenominator}; + const base = {numerator: baseRatioNumerator, denominator: baseRatioDenominator}; + if (!ratioAtLeast(effective, base)) throw new SenderError(CraftError.EFFECTIVE_RATIO_BELOW_BASE); const assignment = findCraftBountyAssignment(ctx, craftId); if (assignment === null) throw new SenderError(CraftError.NO_BOUNTY_ASSIGNMENT); + const escrow = isEscrowedCurrency(currency) ? findOpenEscrow(ctx, craftId) : null; + if (isEscrowedCurrency(currency) && escrow === null) return; + const existing = [...ctx.db.craft_bounty_entitlement.by_craft_player_currency.filter([craftId, playerId, currency])][0] ?? null; // A gap: this row hasn't been priced since the *current* (continuous) assignment began, so // whatever effort accrued in between (while the bounty was actually cleared) must not be @@ -311,11 +372,62 @@ export const upsertCraftBountyEntitlement = spacetimedb.reducer( // `addToEntitlementTotal` combines it with whatever fraction is still owed from any craft // and floors once, pooled. const deltaFraction = reduceRatio(deltaEffort * ratioNumerator, ratioDenominator); - addToEntitlementTotal(ctx, assignment.assignedByAccountIdentity, playerId, currency, deltaFraction, deltaEffort, updatedAt); + if (escrow === null) { + addToEntitlementTotal(ctx, assignment.assignedByAccountIdentity, playerId, currency, deltaFraction, deltaEffort, updatedAt); + } else { + const secured = ratioAtLeast(base, escrowRatio(escrow)) ? escrowRatio(escrow) : base; + const wanted = reduceRatio(deltaEffort * secured.numerator, secured.denominator); + const accrued = escrowAccrued(escrow); + const headroom = reduceRatio( + (escrow.heldAmount - escrow.releasedAmount) * accrued.denominator - accrued.numerator, accrued.denominator, + ); + const deltaGuaranteed = headroom.numerator <= 0n ? {numerator: 0n, denominator: 1n} + : ratioAtLeast(wanted, headroom) ? headroom : wanted; + const newAccrued = fitFraction(addRatio(accrued, deltaGuaranteed), true); + ctx.db.craft_bounty_escrow.id.update({ + ...escrow, + accruedNumerator: assertFitsI64(newAccrued.numerator), + accruedDenominator: assertFitsI64(newAccrued.denominator), + effortPriced: escrow.effortPriced + deltaEffort, + updatedAt, + }); + addToEscrowPools(ctx, escrow, playerId, deltaFraction, deltaGuaranteed, deltaEffort, updatedAt); + } } } ); +/** Adjusts `payer`'s running (`payeePlayerId`, `currency`) ledger by `delta` and logs it as a recorded payment. */ +function applyBountyPayment(ctx: Ctx, payer: Identity, payeePlayerId: bigint, currency: string, delta: bigint): void { + const existing = [...ctx.db.bounty_payout_record.by_payer_payee.filter([payer, payeePlayerId])] + .find(row => row.currency === currency); + if (existing === undefined) { + ctx.db.bounty_payout_record.insert({ + id: 0n, + payerAccountIdentity: payer, + payeePlayerId, + currency, + paidTotal: delta, + updatedAt: ctx.timestamp, + }); + } else { + ctx.db.bounty_payout_record.id.update({ + ...existing, + paidTotal: existing.paidTotal + delta, + updatedAt: ctx.timestamp, + }); + } + ctx.db.bounty_payout_record_log.insert({ + id: 0n, + payerAccountIdentity: payer, + payeePlayerId, + currency, + amount: delta, + createdAt: ctx.timestamp, + source: 'recorded', + }); +} + /** * Adjusts the caller's own (`ctx.sender`, `payeePlayerId`, `currency`) running ledger by `delta` * (positive or negative). This is independent of any particular craft. The only validation is the @@ -326,32 +438,21 @@ export const recordBountyPayment = spacetimedb.reducer( (ctx, {payeePlayerId, currency, delta}) => { requireAccount(ctx); validateManualBountyCurrency(currency); - const existing = [...ctx.db.bounty_payout_record.by_payer_payee.filter([ctx.sender, payeePlayerId])] - .find(row => row.currency === currency); - if (existing === undefined) { - ctx.db.bounty_payout_record.insert({ - id: 0n, - payerAccountIdentity: ctx.sender, - payeePlayerId, - currency, - paidTotal: delta, - updatedAt: ctx.timestamp, - }); - } else { - ctx.db.bounty_payout_record.id.update({ - ...existing, - paidTotal: existing.paidTotal + delta, - updatedAt: ctx.timestamp, - }); - } - ctx.db.bounty_payout_record_log.insert({ - id: 0n, - payerAccountIdentity: ctx.sender, - payeePlayerId, - currency, - amount: delta, - createdAt: ctx.timestamp, - }); + applyBountyPayment(ctx, ctx.sender, payeePlayerId, currency, delta); + } +); + +/** + * Service-principal-only `recordBountyPayment` on behalf of `payerAccountIdentity`, which must + * have an account. + */ +export const recordBountyPaymentFor = spacetimedb.reducer( + {payerAccountIdentity: t.identity(), payeePlayerId: t.u64(), currency: t.string(), delta: t.i64()}, + (ctx, {payerAccountIdentity, payeePlayerId, currency, delta}) => { + requireServicePrincipal(ctx); + if (ctx.db.account.identity.find(payerAccountIdentity) === null) throw new SenderError(CraftError.UNKNOWN_BRICO_ACCOUNT); + validateManualBountyCurrency(currency); + applyBountyPayment(ctx, payerAccountIdentity, payeePlayerId, currency, delta); } ); diff --git a/backend/brico-module/spacetimedb/src/schema.ts b/backend/brico-module/spacetimedb/src/schema.ts index c7dc80ed..f209d2b2 100644 --- a/backend/brico-module/spacetimedb/src/schema.ts +++ b/backend/brico-module/spacetimedb/src/schema.ts @@ -3,10 +3,23 @@ import {schema} from 'spacetimedb/server'; import {account, service_principal} from './tables/accounts'; import {bounty_rule, craft_bounty_assignment, craft_bounty_override, craft_private_bounty_assignment} from './tables/bounties'; import {craft_filter_watch, saved_craft_filter, shared_filter} from './tables/crafts'; -import {discord_guild_install, discord_notify_sink, discord_notify_target, discord_watch_display} from './tables/discord'; +import {discord_funding_notify_target, discord_guild_install, discord_notify_sink, discord_notify_target, discord_watch_display} from './tables/discord'; +import { + bounty_escrow_journal, + bounty_escrow_pool, + bounty_escrow_wallet, + craft_bounty_escrow, + craft_bounty_funding, + stelo_currency_policy, + stelo_payout_schedule, + stelo_receiving_wallet, + stelo_transfer, + stelo_wallet, + stelo_wallet_status +} from './tables/escrow'; import {integration_link_request, linked_integration} from './tables/integrations'; -import {notification} from './tables/notifications'; -import {craft_filter_notify_trigger} from './tables/notify'; +import {notification, notification_v2} from './tables/notifications'; +import {craft_filter_notify_trigger, funding_notify_trigger} from './tables/notify'; import { bounty_entitlement_total, bounty_payout_record, @@ -26,6 +39,7 @@ const spacetimedb = schema({ craft_filter_watch, shared_filter, notification, + notification_v2, bounty_rule, craft_bounty_override, craft_bounty_assignment, @@ -42,6 +56,19 @@ const spacetimedb = schema({ discord_notify_target, craft_filter_notify_trigger, discord_guild_install, + craft_bounty_escrow, + bounty_escrow_wallet, + bounty_escrow_pool, + bounty_escrow_journal, + stelo_transfer, + stelo_currency_policy, + stelo_payout_schedule, + stelo_wallet, + stelo_receiving_wallet, + craft_bounty_funding, + stelo_wallet_status, + funding_notify_trigger, + discord_funding_notify_target, }); export {spacetimedb}; diff --git a/backend/brico-module/spacetimedb/src/tables/bounties.ts b/backend/brico-module/spacetimedb/src/tables/bounties.ts index ed0b961c..161a00eb 100644 --- a/backend/brico-module/spacetimedb/src/tables/bounties.ts +++ b/backend/brico-module/spacetimedb/src/tables/bounties.ts @@ -48,6 +48,8 @@ export const bounty_rule = table( createdAt: t.timestamp(), updatedAt: t.timestamp(), deletedAt: t.option(t.timestamp()), + /** The caller's Stelo wallet (`linked_integration.id`) that funds this rule's bounties; required for an escrowed currency, absent otherwise. */ + fundingLinkId: t.option(t.u64()).default(undefined), } ); @@ -67,6 +69,8 @@ export const craft_bounty_override = table( /** When true, this override's assignment goes to `craft_private_bounty_assignment` instead of the public `craft_bounty_assignment`. */ private: t.bool(), updatedAt: t.timestamp(), + /** Same meaning as `bounty_rule.fundingLinkId`. */ + fundingLinkId: t.option(t.u64()).default(undefined), } ); @@ -87,6 +91,8 @@ export const craft_bounty_assignment = table( assignedByAccountIdentity: t.identity(), assignedAt: t.timestamp(), updatedAt: t.timestamp(), + /** False when the assignment came from the craft's override: removing the override sends the craft back to its rules. True (the default) keeps the assignment against rule edits and deletions. */ + sticky: t.bool().default(true), } ); @@ -106,5 +112,7 @@ export const craft_private_bounty_assignment = table( assignedByAccountIdentity: t.identity().index('btree'), assignedAt: t.timestamp(), updatedAt: t.timestamp(), + /** False when the assignment came from the craft's override: removing the override sends the craft back to its rules. True (the default) keeps the assignment against rule edits and deletions. */ + sticky: t.bool().default(true), } ); diff --git a/backend/brico-module/spacetimedb/src/tables/discord.ts b/backend/brico-module/spacetimedb/src/tables/discord.ts index 4e8ac6ab..987c542d 100644 --- a/backend/brico-module/spacetimedb/src/tables/discord.ts +++ b/backend/brico-module/spacetimedb/src/tables/discord.ts @@ -108,3 +108,26 @@ export const discord_guild_install = table( updatedAt: t.timestamp(), } ); + +/** + * Discord wording and mention config for one (account, Discord sink) pair's funding alerts; on/off + * lives in `funding_notify_trigger`. An `undefined` template means the default + * (`@brico/crafts/stelo`'s `DEFAULT_FUNDING_NOTIFY_TEMPLATE`). `mention*` overrides the sink's + * `defaultMention*` and is written only by `attachDiscordFundingTarget`. + */ +export const discord_funding_notify_target = table( + {name: 'discord_funding_notify_target'}, + { + id: t.string().primaryKey(), // client- or bot-generated uuid + accountIdentity: t.identity().index('btree'), + sinkId: t.string(), // discord_notify_sink.id + fundingFailedTemplate: t.option(t.string()), + payoutShortfallTemplate: t.option(t.string()), + unclaimedTemplate: t.option(t.string()), + mentionType: t.option(t.string()), // 'user' | 'role' + mentionId: t.option(t.string()), + mentionName: t.option(t.string()), + updatedAt: t.timestamp(), + deletedAt: t.option(t.timestamp()), + } +); diff --git a/backend/brico-module/spacetimedb/src/tables/escrow.ts b/backend/brico-module/spacetimedb/src/tables/escrow.ts new file mode 100644 index 00000000..f5473cf3 --- /dev/null +++ b/backend/brico-module/spacetimedb/src/tables/escrow.ts @@ -0,0 +1,267 @@ +import {t, table} from 'spacetimedb/server'; + +// All tables here are private and written only by service-principal reducers (or by reducers acting +// on the caller's own wallet rows); see `views/escrow.ts` for what is exposed. + +/** + * One row per funded period of a craft's escrowed bounty; at most one open row (`closedAt` unset) + * per craft, enforced in reducers. `fundingAccountId` is the Stelo account that paid for the hold, so + * refunds still work after the wallet is unlinked on brico. + * + * `held − released ≥ ceil(accrued + ratio × (effortCovered − effortPriced))` while the craft is + * assigned; `accrued` is the exact guaranteed value credited so far. + */ +export const craft_bounty_escrow = table( + {name: 'craft_bounty_escrow'}, + { + id: t.u64().primaryKey().autoInc(), + craftId: t.u64().index('btree'), + payerAccountIdentity: t.identity().index('btree'), + currency: t.string(), + fundingLinkId: t.u64(), + fundingAccountId: t.u64(), + /** Whole units pulled for this craft (including refund due re-reserved for a raise). */ + heldAmount: t.i64(), + /** Cumulative units given up (rate cut, bounty removed, craft ended). */ + releasedAmount: t.i64(), + ratioNumerator: t.i64(), + ratioDenominator: t.i64(), + effortCovered: t.i64(), + effortPriced: t.i64(), + accruedNumerator: t.i64(), + accruedDenominator: t.i64(), + heldAt: t.timestamp(), + updatedAt: t.timestamp(), + closedAt: t.option(t.timestamp()), + } +); + +/** Released money still physically in escrow, owed back to one paying wallet. One row per (payer, currency, paying wallet). */ +export const bounty_escrow_wallet = table( + { + name: 'bounty_escrow_wallet', + indexes: [{accessor: 'by_payer_currency', algorithm: 'btree', columns: ['payerAccountIdentity', 'currency']}], + }, + { + id: t.u64().primaryKey().autoInc(), + payerAccountIdentity: t.identity(), + currency: t.string(), + fundingAccountId: t.u64(), + refundDue: t.i64(), + updatedAt: t.timestamp(), + } +); + +/** + * Fractional-carry pool for one (payer, payee, currency, paying wallet): `total = floor(F)` and + * `guaranteedTotal = floor(G)`, each with its own `< 1` carry. `bounty_entitlement_total.total` is the + * sum of its pools' `total`s. + */ +export const bounty_escrow_pool = table( + { + name: 'bounty_escrow_pool', + indexes: [{accessor: 'by_payer_payee', algorithm: 'btree', columns: ['payerAccountIdentity', 'payeePlayerId']}], + }, + { + id: t.u64().primaryKey().autoInc(), + payerAccountIdentity: t.identity(), + payeePlayerId: t.u64().index('btree'), + currency: t.string(), + fundingAccountId: t.u64(), + total: t.i64(), + remainderNumerator: t.i64(), + remainderDenominator: t.i64(), + guaranteedTotal: t.i64(), + guaranteedRemainderNumerator: t.i64(), + guaranteedRemainderDenominator: t.i64(), + /** Paid out of escrow, or closed by an unclaimed rollover/refund. */ + paidEscrow: t.i64(), + /** Loyalty extras paid out, which were pulled from the paying wallet into escrow first. */ + paidDirect: t.i64(), + /** Loyalty extras pulled into escrow and not yet paid out; owed back to the paying wallet if they turn out not to be needed. */ + pulledExtras: t.i64(), + /** First sweep that found money owed but no routable payout wallet; cleared by a successful payout. */ + unclaimedSince: t.option(t.timestamp()), + updatedAt: t.timestamp(), + } +); + +/** Append-only ledger of physical movement into and out of escrow (`amount` is signed: + in, − out). */ +export const bounty_escrow_journal = table( + {name: 'bounty_escrow_journal'}, + { + id: t.u64().primaryKey().autoInc(), + payerAccountIdentity: t.identity().index('btree'), + currency: t.string(), + /** One of `JOURNAL_KINDS` in `lib/escrow.ts`. */ + kind: t.string(), + amount: t.i64(), + fundingAccountId: t.u64(), + craftId: t.option(t.u64()), + payeePlayerId: t.option(t.u64()), + transferId: t.u64(), + createdAt: t.timestamp(), + } +); + +export const SteloTransferState = t.enum('SteloTransferState', { + pending: t.unit(), + settled: t.unit(), + failed: t.unit(), +}); + +/** What a Stelo transfer is for. Purpose-specific parameters live in the variant payload. */ +export const SteloTransferPurpose = t.enum('SteloTransferPurpose', { + hold: t.object('SteloHold', { + craftId: t.u64(), + fundingLinkId: t.u64(), + ratioNumerator: t.i64(), + ratioDenominator: t.i64(), + effortCovered: t.i64(), + }), + topUp: t.object('SteloTopUp', { + craftId: t.u64(), + ratioNumerator: t.i64(), + ratioDenominator: t.i64(), + /** Units of the craft's wallet's refund due re-reserved by this raise instead of pulled. */ + reserved: t.i64(), + }), + /** Escrow wallet to the payee's receiving wallet; `extras` of its amount are pulled loyalty extras, the rest guaranteed. */ + payout: t.object('SteloPayout', { + payeePlayerId: t.u64(), + fundingAccountId: t.u64(), + extras: t.i64(), + }), + refund: t.object('SteloRefund', { + fundingAccountId: t.u64(), + }), + /** Paying wallet to escrow wallet: a pool's loyalty extras, pulled so one payout can carry them. */ + extras: t.object('SteloExtras', { + payeePlayerId: t.u64(), + fundingAccountId: t.u64(), + }), + /** One of the payer's own wallets to any wallet, outside the escrow books (the Discord `/pay stelo` command). */ + direct: t.object('SteloDirect', {}), +}); + +/** + * Write-ahead record of one Stelo transfer. `ref` (``, ≤ 32 bytes) is both the Stelo memo + * and idempotency key, so replaying the send can't duplicate it. + */ +export const stelo_transfer = table( + {name: 'stelo_transfer'}, + { + id: t.u64().primaryKey().autoInc(), + ref: t.string().unique(), + currency: t.string(), + payerAccountIdentity: t.identity().index('btree'), + fromAccountId: t.u64(), + toAccountId: t.u64(), + /** Whole brico units. */ + amount: t.i64(), + /** Raw Stelo units (`amount × 10^ledger.scale`), frozen at intent time. */ + steloAmount: t.u64(), + purpose: SteloTransferPurpose, + state: SteloTransferState, + steloTransferId: t.option(t.u64()), + failureReason: t.option(t.string()), + createdAt: t.timestamp(), + settledAt: t.option(t.timestamp()), + } +); + +/** Singleton (`id = 0`): when the interval payout sweep last ran, and how often it runs. */ +export const stelo_payout_schedule = table( + {name: 'stelo_payout_schedule', public: true}, + { + id: t.u8().primaryKey(), + intervalMicros: t.i64(), + lastSweepAt: t.timestamp(), + } +); + +/** + * What happens to a payee's escrow-backed money when they have no routable wallet, per escrowed currency. + * Published from `brico-bot`'s configuration so the UI can state it; the bot applies it, not the module. + */ +export const stelo_currency_policy = table( + {name: 'stelo_currency_policy', public: true}, + { + currency: t.string().primaryKey(), + /** 'rollover' | 'refund'. */ + action: t.string(), + /** The manually recorded currency credited to the payee on a rollover. */ + rollTo: t.option(t.string()), + /** Days after a payee is first found unroutable before the money is closed out. */ + afterDays: t.u32(), + } +); + +/** One row per active `stelo` link: the escrow currency whose ledger the wallet is on (written by `brico-bot` once it verifies the link). */ +export const stelo_wallet = table( + {name: 'stelo_wallet'}, + { + linkId: t.u64().primaryKey(), + accountIdentity: t.identity().index('btree'), + currency: t.string(), + } +); + +/** The wallet an account designates to receive payouts and refunds in one currency; one per (account, currency), enforced in reducers. */ +export const stelo_receiving_wallet = table( + { + name: 'stelo_receiving_wallet', + indexes: [{accessor: 'by_account_currency', algorithm: 'btree', columns: ['accountIdentity', 'currency']}], + }, + { + id: t.u64().primaryKey().autoInc(), + accountIdentity: t.identity(), + currency: t.string(), + linkId: t.u64(), + } +); + +/** + * Why a craft's escrowed bounty is or isn't funded, one row per craft whose resolved bounty is in an + * escrowed currency; written by `brico-bot` only when the row would change. Private to the payer. + * `state` / `source` / `reason` are validated against the `@brico/crafts/stelo` allow-lists. + */ +export const craft_bounty_funding = table( + {name: 'craft_bounty_funding'}, + { + craftId: t.u64().primaryKey(), + payerAccountIdentity: t.identity().index('btree'), + /** 'override' | 'rule'. */ + source: t.string(), + /** `bounty_rule.id` when `source` is 'rule'. */ + ruleId: t.option(t.string()), + currency: t.string(), + fundingLinkId: t.option(t.u64()), + /** The resolved rate, which differs from the assignment while a raise is pending. */ + ratioNumerator: t.i64(), + ratioDenominator: t.i64(), + /** 'waiting' | 'funding' | 'failed' | 'funded'. */ + state: t.string(), + /** A machine code, never display text. */ + reason: t.option(t.string()), + /** `stelo_transfer.id` of the in-flight or last failed hold/top-up. */ + transferId: t.option(t.u64()), + updatedAt: t.timestamp(), + } +); + +/** + * What `brico-bot` last saw of a wallet link, written by the bot when it changes: whether it can pull + * from the wallet (`canPull`) and whether the linker is still an Admin or Owner member of it + * (`verified`). Only a verified wallet may fund bounties; the bot re-checks both live before every pull. + */ +export const stelo_wallet_status = table( + {name: 'stelo_wallet_status'}, + { + linkId: t.u64().primaryKey(), + accountIdentity: t.identity().index('btree'), + canPull: t.bool(), + verified: t.bool(), + updatedAt: t.timestamp(), + } +); diff --git a/backend/brico-module/spacetimedb/src/tables/integrations.ts b/backend/brico-module/spacetimedb/src/tables/integrations.ts index 0a7e999f..01762b14 100644 --- a/backend/brico-module/spacetimedb/src/tables/integrations.ts +++ b/backend/brico-module/spacetimedb/src/tables/integrations.ts @@ -56,5 +56,15 @@ export const integration_link_request = table( externalId: t.option(t.string()), externalHandle: t.option(t.string()), createdAt: t.timestamp(), + /** `stelo` only: the normalized account address (letters, no `#`) the browser claims to control; `brico-bot` resolves and verifies it before finalizing. */ + candidateAddress: t.option(t.string()).default(undefined), + /** `stelo` only: why verification hasn't passed yet, for the browser to show. */ + pendingReason: t.option(t.string()).default(undefined), + /** `stelo` only: the browser accepts linking the wallet without proving ownership (receive-only). */ + unverified: t.bool().default(false), + /** `stelo` only: the Stelo username of the account the bot matched, shown for confirmation of an unverified link. */ + matchedUsername: t.option(t.string()).default(undefined), + /** `stelo` only: the browser confirmed the match of an unverified link. */ + confirmed: t.bool().default(false), } ); diff --git a/backend/brico-module/spacetimedb/src/tables/notifications.ts b/backend/brico-module/spacetimedb/src/tables/notifications.ts index 3140e154..8451e09a 100644 --- a/backend/brico-module/spacetimedb/src/tables/notifications.ts +++ b/backend/brico-module/spacetimedb/src/tables/notifications.ts @@ -17,16 +17,47 @@ export const CraftNotificationPayload = t.object('CraftNotificationPayload', { ownerName: t.option(t.string()), }); +/** A funding alert's payload: ids and machine codes only, never display text. */ +export const FundingNotificationPayload = t.object('FundingNotificationPayload', { + /** One of `FUNDING_NOTIFICATION_KINDS` in `@brico/crafts/stelo`. */ + kind: t.string(), + /** 'payer' | 'payee'. */ + role: t.string(), + currency: t.string(), + craftId: t.option(t.u64()), + amount: t.option(t.i64()), + reason: t.option(t.string()), + /** Unclaimed-money events: who the money was owed by and to. */ + payerName: t.option(t.string()), + payeeName: t.option(t.string()), +}); + /** - * Generic from the start: one row shape, a sum-typed payload column that varies per source - * (`craft` today, others later) — a future watchable type adds a variant here, not a second table. + * Payload sum type of the legacy `notification` table. Adding a variant is a breaking schema + * change, so it stays frozen; new sources only extend `notification_v2`'s JSON payload. */ export const NotificationPayload = t.enum('NotificationPayload', { craft: CraftNotificationPayload, }); +/** Legacy notification rows; `migrateNotificationsToV2` drains it into `notification_v2`. Nothing writes to it. */ +export const notification = table( + { + name: 'notification', + }, + { + id: t.u64().primaryKey().autoInc(), + accountIdentity: t.identity().index('btree'), + payload: NotificationPayload, + createdAt: t.timestamp(), + readAt: t.option(t.timestamp()), + } +); + /** - * One notification for one account. Hard-deleted on removal. + * One notification for one account. Hard-deleted on removal. `payloadJson` is a + * `NotificationPayload` from `@brico/crafts/notification`; readers skip tags they don't know, so a + * new source needs no schema change. * * No dedup/idempotency index: `createWatchMatcher` only emits events after its first (silent, * priming) `update()` call, and a `brico-bot` restart always rebuilds matchers unprimed, so a @@ -34,14 +65,14 @@ export const NotificationPayload = t.enum('NotificationPayload', { * a failure mode the code cannot currently produce, and would fight a legitimate case (the same * filter/craft/event triple recurring later, e.g. added -> removed -> added again). */ -export const notification = table( +export const notification_v2 = table( { - name: 'notification', + name: 'notification_v2', }, { id: t.u64().primaryKey().autoInc(), accountIdentity: t.identity().index('btree'), - payload: NotificationPayload, + payloadJson: t.string(), createdAt: t.timestamp(), readAt: t.option(t.timestamp()), } diff --git a/backend/brico-module/spacetimedb/src/tables/notify.ts b/backend/brico-module/spacetimedb/src/tables/notify.ts index 0ca362f8..aa0f2f25 100644 --- a/backend/brico-module/spacetimedb/src/tables/notify.ts +++ b/backend/brico-module/spacetimedb/src/tables/notify.ts @@ -28,3 +28,22 @@ export const craft_filter_notify_trigger = table( deletedAt: t.option(t.timestamp()), } ); + +/** + * Per-(account, sink) on/off flags for funding alerts, independent of any saved filter. With no + * row the toast sink is on for every event and Discord is off; an explicit row overrides that. + * Wording and mentions for a Discord sink live in `discord_funding_notify_target`. + */ +export const funding_notify_trigger = table( + {name: 'funding_notify_trigger'}, + { + id: t.string().primaryKey(), // client- (toast) or bot- (discord) generated uuid + accountIdentity: t.identity().index('btree'), + sink: NotifySinkRef, + fundingFailed: t.bool(), + payoutShortfall: t.bool(), + unclaimed: t.bool(), + updatedAt: t.timestamp(), + deletedAt: t.option(t.timestamp()), + } +); diff --git a/backend/brico-module/spacetimedb/src/tables/payouts.ts b/backend/brico-module/spacetimedb/src/tables/payouts.ts index 528b01b9..3c2f2b5d 100644 --- a/backend/brico-module/spacetimedb/src/tables/payouts.ts +++ b/backend/brico-module/spacetimedb/src/tables/payouts.ts @@ -203,5 +203,7 @@ export const bounty_payout_record_log = table( currency: t.string(), amount: t.i64(), createdAt: t.timestamp(), + /** `recorded` (manual), `transfer` (Stelo payout), `rollover` or `forfeit` (unclaimed escrow money closed out). */ + source: t.string().default('recorded'), } ); diff --git a/backend/brico-module/spacetimedb/src/views/discord.ts b/backend/brico-module/spacetimedb/src/views/discord.ts index 413af454..900cd9ed 100644 --- a/backend/brico-module/spacetimedb/src/views/discord.ts +++ b/backend/brico-module/spacetimedb/src/views/discord.ts @@ -2,7 +2,7 @@ import {t} from 'spacetimedb/server'; import {spacetimedb} from '../schema'; -import {discord_guild_install, discord_notify_sink, discord_notify_target, discord_watch_display} from '../tables/discord'; +import {discord_funding_notify_target, discord_guild_install, discord_notify_sink, discord_notify_target, discord_watch_display} from '../tables/discord'; /** The caller's own watch displays, tombstoned ones included — same reason as `mySavedCraftFilter`. */ export const myDiscordWatchDisplay = spacetimedb.view( @@ -64,3 +64,20 @@ export const allDiscordGuildInstall = spacetimedb.view( return ctx.from.discord_guild_install.where(_ => isTrusted); } ); + +/** The caller's own Discord funding-alert targets, tombstoned ones included. */ +export const myDiscordFundingNotifyTarget = spacetimedb.view( + {name: 'my_discord_funding_notify_target', public: true}, + t.array(discord_funding_notify_target.rowType), + ctx => ctx.from.discord_funding_notify_target.where(d => d.accountIdentity.eq(ctx.sender)) +); + +/** Unfiltered funding-target feed for brico-bot. Same trusted-constant-predicate query view as `all_account`. */ +export const allDiscordFundingNotifyTarget = spacetimedb.view( + {name: 'all_discord_funding_notify_target', public: true}, + t.array(discord_funding_notify_target.rowType), + ctx => { + const isTrusted = ctx.db.service_principal.identity.find(ctx.sender) !== null; + return ctx.from.discord_funding_notify_target.where(_ => isTrusted); + } +); diff --git a/backend/brico-module/spacetimedb/src/views/escrow.ts b/backend/brico-module/spacetimedb/src/views/escrow.ts new file mode 100644 index 00000000..3fed5582 --- /dev/null +++ b/backend/brico-module/spacetimedb/src/views/escrow.ts @@ -0,0 +1,147 @@ +// noinspection JSUnusedGlobalSymbols + +import {t} from 'spacetimedb/server'; +import {spacetimedb} from '../schema'; +import { + bounty_escrow_journal, + bounty_escrow_pool, + bounty_escrow_wallet, + craft_bounty_escrow, + craft_bounty_funding, + stelo_receiving_wallet, + stelo_transfer, + stelo_wallet, + stelo_wallet_status +} from '../tables/escrow'; + +// Every `all*` view below is the unfiltered feed `brico-bot` reads; it is empty for anyone else. + +/** The caller's own escrow holds, as a payer. */ +export const myCraftBountyEscrow = spacetimedb.view( + {name: 'my_craft_bounty_escrow', public: true}, + t.array(craft_bounty_escrow.rowType), + ctx => ctx.from.craft_bounty_escrow.where(r => r.payerAccountIdentity.eq(ctx.sender)) +); + +/** What each of the caller's paying wallets is still owed back from escrow. */ +export const myBountyEscrowWallet = spacetimedb.view( + {name: 'my_bounty_escrow_wallet', public: true}, + t.array(bounty_escrow_wallet.rowType), + ctx => ctx.from.bounty_escrow_wallet.where(r => r.payerAccountIdentity.eq(ctx.sender)) +); + +/** The caller's own Stelo transfers as a payer, so funding failures are visible to them. */ +export const mySteloTransfer = spacetimedb.view( + {name: 'my_stelo_transfer', public: true}, + t.array(stelo_transfer.rowType), + ctx => ctx.from.stelo_transfer.where(r => r.payerAccountIdentity.eq(ctx.sender)) +); + +/** The caller's registered Stelo wallets. */ +export const mySteloWallet = spacetimedb.view( + {name: 'my_stelo_wallet', public: true}, + t.array(stelo_wallet.rowType), + ctx => ctx.from.stelo_wallet.where(r => r.accountIdentity.eq(ctx.sender)) +); + +/** The caller's receiving wallet per currency. */ +export const mySteloReceivingWallet = spacetimedb.view( + {name: 'my_stelo_receiving_wallet', public: true}, + t.array(stelo_receiving_wallet.rowType), + ctx => ctx.from.stelo_receiving_wallet.where(r => r.accountIdentity.eq(ctx.sender)) +); + +/** Why each of the caller's own escrowed bounties is or isn't funded. */ +export const myCraftBountyFunding = spacetimedb.view( + {name: 'my_craft_bounty_funding', public: true}, + t.array(craft_bounty_funding.rowType), + ctx => ctx.from.craft_bounty_funding.where(r => r.payerAccountIdentity.eq(ctx.sender)) +); + +/** Whether the bot can pull from each of the caller's registered wallets. */ +export const mySteloWalletStatus = spacetimedb.view( + {name: 'my_stelo_wallet_status', public: true}, + t.array(stelo_wallet_status.rowType), + ctx => ctx.from.stelo_wallet_status.where(r => r.accountIdentity.eq(ctx.sender)) +); + +export const allCraftBountyFunding = spacetimedb.view( + {name: 'all_craft_bounty_funding', public: true}, + t.array(craft_bounty_funding.rowType), + ctx => { + const isTrusted = ctx.db.service_principal.identity.find(ctx.sender) !== null; + return ctx.from.craft_bounty_funding.where(_ => isTrusted); + } +); + +export const allSteloWalletStatus = spacetimedb.view( + {name: 'all_stelo_wallet_status', public: true}, + t.array(stelo_wallet_status.rowType), + ctx => { + const isTrusted = ctx.db.service_principal.identity.find(ctx.sender) !== null; + return ctx.from.stelo_wallet_status.where(_ => isTrusted); + } +); + +export const allCraftBountyEscrow = spacetimedb.view( + {name: 'all_craft_bounty_escrow', public: true}, + t.array(craft_bounty_escrow.rowType), + ctx => { + const isTrusted = ctx.db.service_principal.identity.find(ctx.sender) !== null; + return ctx.from.craft_bounty_escrow.where(_ => isTrusted); + } +); + +export const allBountyEscrowWallet = spacetimedb.view( + {name: 'all_bounty_escrow_wallet', public: true}, + t.array(bounty_escrow_wallet.rowType), + ctx => { + const isTrusted = ctx.db.service_principal.identity.find(ctx.sender) !== null; + return ctx.from.bounty_escrow_wallet.where(_ => isTrusted); + } +); + +export const allBountyEscrowPool = spacetimedb.view( + {name: 'all_bounty_escrow_pool', public: true}, + t.array(bounty_escrow_pool.rowType), + ctx => { + const isTrusted = ctx.db.service_principal.identity.find(ctx.sender) !== null; + return ctx.from.bounty_escrow_pool.where(_ => isTrusted); + } +); + +export const allBountyEscrowJournal = spacetimedb.view( + {name: 'all_bounty_escrow_journal', public: true}, + t.array(bounty_escrow_journal.rowType), + ctx => { + const isTrusted = ctx.db.service_principal.identity.find(ctx.sender) !== null; + return ctx.from.bounty_escrow_journal.where(_ => isTrusted); + } +); + +export const allSteloTransfer = spacetimedb.view( + {name: 'all_stelo_transfer', public: true}, + t.array(stelo_transfer.rowType), + ctx => { + const isTrusted = ctx.db.service_principal.identity.find(ctx.sender) !== null; + return ctx.from.stelo_transfer.where(_ => isTrusted); + } +); + +export const allSteloWallet = spacetimedb.view( + {name: 'all_stelo_wallet', public: true}, + t.array(stelo_wallet.rowType), + ctx => { + const isTrusted = ctx.db.service_principal.identity.find(ctx.sender) !== null; + return ctx.from.stelo_wallet.where(_ => isTrusted); + } +); + +export const allSteloReceivingWallet = spacetimedb.view( + {name: 'all_stelo_receiving_wallet', public: true}, + t.array(stelo_receiving_wallet.rowType), + ctx => { + const isTrusted = ctx.db.service_principal.identity.find(ctx.sender) !== null; + return ctx.from.stelo_receiving_wallet.where(_ => isTrusted); + } +); diff --git a/backend/brico-module/spacetimedb/src/views/notifications.ts b/backend/brico-module/spacetimedb/src/views/notifications.ts index 01ebce58..f3cbaf2a 100644 --- a/backend/brico-module/spacetimedb/src/views/notifications.ts +++ b/backend/brico-module/spacetimedb/src/views/notifications.ts @@ -1,6 +1,6 @@ import {t} from 'spacetimedb/server'; import {spacetimedb} from '../schema'; -import {notification} from '../tables/notifications'; +import {notification_v2} from '../tables/notifications'; // noinspection JSUnusedGlobalSymbols /** @@ -8,8 +8,8 @@ import {notification} from '../tables/notifications'; */ export const myNotification = spacetimedb.view( {name: 'my_notification', public: true}, - t.array(notification.rowType), - ctx => ctx.from.notification.where(n => n.accountIdentity.eq(ctx.sender)) + t.array(notification_v2.rowType), + ctx => ctx.from.notification_v2.where(n => n.accountIdentity.eq(ctx.sender)) ); // No bot `all_` view, as it never needs to read notifications, only write them. diff --git a/backend/brico-module/spacetimedb/src/views/notify.ts b/backend/brico-module/spacetimedb/src/views/notify.ts index 87d812c8..b69395b8 100644 --- a/backend/brico-module/spacetimedb/src/views/notify.ts +++ b/backend/brico-module/spacetimedb/src/views/notify.ts @@ -2,7 +2,7 @@ import {t} from 'spacetimedb/server'; import {spacetimedb} from '../schema'; -import {craft_filter_notify_trigger} from '../tables/notify'; +import {craft_filter_notify_trigger, funding_notify_trigger} from '../tables/notify'; /** The caller's own notification trigger rows, tombstoned ones included — same reason as `mySavedCraftFilter`. */ export const myCraftFilterNotifyTrigger = spacetimedb.view( @@ -20,3 +20,20 @@ export const allCraftFilterNotifyTrigger = spacetimedb.view( return ctx.from.craft_filter_notify_trigger.where(_ => isTrusted); } ); + +/** The caller's own funding-alert triggers, tombstoned ones included. */ +export const myFundingNotifyTrigger = spacetimedb.view( + {name: 'my_funding_notify_trigger', public: true}, + t.array(funding_notify_trigger.rowType), + ctx => ctx.from.funding_notify_trigger.where(r => r.accountIdentity.eq(ctx.sender)) +); + +/** Unfiltered funding-trigger feed for brico-bot. Same trusted-constant-predicate query view as `all_account`. */ +export const allFundingNotifyTrigger = spacetimedb.view( + {name: 'all_funding_notify_trigger', public: true}, + t.array(funding_notify_trigger.rowType), + ctx => { + const isTrusted = ctx.db.service_principal.identity.find(ctx.sender) !== null; + return ctx.from.funding_notify_trigger.where(_ => isTrusted); + } +); diff --git a/backend/brico-module/spacetimedb/src/views/payouts.ts b/backend/brico-module/spacetimedb/src/views/payouts.ts index 9bae0fe0..1e42f857 100644 --- a/backend/brico-module/spacetimedb/src/views/payouts.ts +++ b/backend/brico-module/spacetimedb/src/views/payouts.ts @@ -145,6 +145,7 @@ const BountyPayoutLogRow = t.row('BountyPayoutLogRow', { currency: t.string(), amount: t.i64(), createdAt: t.timestamp(), + source: t.string(), }); /** The caller's own transaction log as a payer — every row `recordBountyPayment` appended under @@ -162,6 +163,7 @@ export const myBountyPayoutRecordLogAsPayer = spacetimedb.view( currency: row.currency, amount: row.amount, createdAt: row.createdAt, + source: row.source, })); } ); @@ -187,6 +189,7 @@ export const myBountyPayoutRecordLogAsContributor = spacetimedb.view( currency: row.currency, amount: row.amount, createdAt: row.createdAt, + source: row.source, })) ); } @@ -223,6 +226,8 @@ const PayerEntitlementRow = t.row('PayerEntitlementRow', { currency: t.string(), effortTotal: t.i64(), earnedTotal: t.i64(), + /** The part of `earnedTotal` an escrow hold covers; 0 for a currency without escrow. The rest is unsecured loyalty uplift, so payees never see it. */ + guaranteedTotal: t.i64(), paidTotal: t.i64(), }); @@ -299,7 +304,7 @@ export const myEntitlementsAsPayer = spacetimedb.view( t.array(PayerEntitlementRow), ctx => { const keyOf = (playerId: bigint, currency: string) => `${playerId}:${currency}`; - const rows = new Map(); + const rows = new Map(); for (const total of ctx.db.bounty_entitlement_total.by_payer_payee.filter(ctx.sender)) { rows.set(keyOf(total.payeePlayerId, total.currency), { @@ -307,9 +312,14 @@ export const myEntitlementsAsPayer = spacetimedb.view( currency: total.currency, effortTotal: total.totalEffort, earnedTotal: total.total, + guaranteedTotal: 0n, paidTotal: 0n, }); } + for (const pool of ctx.db.bounty_escrow_pool.by_payer_payee.filter(ctx.sender)) { + const row = rows.get(keyOf(pool.payeePlayerId, pool.currency)); + if (row) row.guaranteedTotal += pool.guaranteedTotal; + } for (const paid of ctx.db.bounty_payout_record.by_payer_payee.filter(ctx.sender)) { const key = keyOf(paid.payeePlayerId, paid.currency); const row = rows.get(key); @@ -319,6 +329,7 @@ export const myEntitlementsAsPayer = spacetimedb.view( currency: paid.currency, effortTotal: 0n, earnedTotal: 0n, + guaranteedTotal: 0n, paidTotal: paid.paidTotal, }); } @@ -333,6 +344,7 @@ export const myEntitlementsAsPayer = spacetimedb.view( currency: row.currency, effortTotal: row.effortTotal, earnedTotal: row.earnedTotal, + guaranteedTotal: row.guaranteedTotal, paidTotal: row.paidTotal, }; }); diff --git a/common/bindings/brico-app/README.md b/common/bindings/brico-app/README.md deleted file mode 100644 index fd089352..00000000 --- a/common/bindings/brico-app/README.md +++ /dev/null @@ -1,61 +0,0 @@ -# `brico-app` bindings - -Generated SpacetimeDB 2.x client bindings for the `brico-app` module (`backend/brico-module/`), -imported as `@brico/bindings/brico-app`. Generated output — do not hand-edit. Regenerate with: - -```sh -npm run generate --prefix backend/brico-module/spacetimedb -``` - -(`backend/brico-module` is deliberately not a workspace member, hence `--prefix` rather than -`--workspace`.) - -## What is and isn't here - -`account`, `linked_integration`, `service_principal`, `saved_craft_filter` and -`craft_filter_watch` are **private** tables — the CLI reports `Skipping private tables during -codegen` and no client can subscribe to them. Clients read through views instead, so the only -table accessors on `DbConnection` are: - -| accessor | SQL name | contents | -| --- | --- | --- | -| `conn.db.myAccount` | `my_account` | the caller's own `account` row, or nothing | -| `conn.db.myLinkedIntegration` | `my_linked_integration` | the caller's own integration links | -| `conn.db.mySavedCraftFilter` | `my_saved_craft_filter` | the caller's own saved craft filters, tombstoned ones included | -| `conn.db.myCraftFilterWatch` | `my_craft_filter_watch` | the caller's own filter watches, tombstoned ones included | -| `conn.db.allAccount` | `all_account` | every `account` row — empty unless the caller is a registered service principal | -| `conn.db.allLinkedIntegration` | `all_linked_integration` | ditto for integration links | -| `conn.db.allSavedCraftFilter` | `all_saved_craft_filter` | ditto for saved craft filters | -| `conn.db.allCraftFilterWatch` | `all_craft_filter_watch` | ditto for filter watches | - -`my_*` views filter by `ctx.sender`; `all_*` views are the same kind of query view but with a -constant `service_principal`-membership predicate instead of a per-row one. That distinction is -invisible from here — a client cannot tell a view from a table. - -Row types (`Account`, `LinkedIntegration`, `ServicePrincipal`, `SavedCraftFilter`, -`CraftFilterWatch`) live in `@brico/bindings/brico-app/types`; reducer arg types in -`@brico/bindings/brico-app/types/reducers`. Filter/watch sync reducers: -`conn.reducers.upsertSavedCraftFilter({...})`, `deleteSavedCraftFilter({...})`, -`upsertCraftFilterWatch({...})`, `deleteCraftFilterWatch({...})` — each single-params-object, same -as `setDisplayName`. - -## `ensureAccount()` is required - -No `account` row exists until a client calls it — connecting does not create one. Call -`conn.reducers.ensureAccount({})` once after the connection opens; it is idempotent. Note the -**`{}` is required** — an argumentless reducer still generates a one-parameter accessor, so -`ensureAccount()` is a type error. `brico-bot` must *not* call it: service principals are refused, -since a trusted reader is not a user. - -## Two naming gotchas versus `../prism` - -- **Accessors are camelCase here, snake_case in `prism`.** `conn.db.myAccount` / - `reducers.setDisplayName`, not `conn.db.my_account`. `prism` is a Rust module generated by an - older CLI; this one is a TS module generated by CLI 2.9. SQL strings are still snake_case - (`SELECT * FROM my_account`), and `tables.myAccount` is the query-builder handle to pass to - `.subscribe([...])`. -- **Reducers take a single params object**, e.g. `conn.reducers.setDisplayName({ displayName })`. - -`types.ts` also emits empty `t.object("MyAccount", {})` placeholders for each view. Those are a -codegen artifact, not the row shape — use `Account` / `LinkedIntegration`, or the default export of -the matching `*_table.ts`. diff --git a/common/bindings/brico-app/all_bounty_escrow_journal_table.ts b/common/bindings/brico-app/all_bounty_escrow_journal_table.ts new file mode 100644 index 00000000..bf0829b6 --- /dev/null +++ b/common/bindings/brico-app/all_bounty_escrow_journal_table.ts @@ -0,0 +1,19 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import {t as __t,} from "spacetimedb"; + +export default __t.row({ + id: __t.u64().primaryKey(), + payerAccountIdentity: __t.identity().name("payer_account_identity"), + currency: __t.string(), + kind: __t.string(), + amount: __t.i64(), + fundingAccountId: __t.u64().name("funding_account_id"), + craftId: __t.option(__t.u64()).name("craft_id"), + payeePlayerId: __t.option(__t.u64()).name("payee_player_id"), + transferId: __t.u64().name("transfer_id"), + createdAt: __t.timestamp().name("created_at"), +}); diff --git a/common/bindings/brico-app/all_bounty_escrow_pool_table.ts b/common/bindings/brico-app/all_bounty_escrow_pool_table.ts new file mode 100644 index 00000000..203d49d2 --- /dev/null +++ b/common/bindings/brico-app/all_bounty_escrow_pool_table.ts @@ -0,0 +1,25 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import {t as __t,} from "spacetimedb"; + +export default __t.row({ + id: __t.u64().primaryKey(), + payerAccountIdentity: __t.identity().name("payer_account_identity"), + payeePlayerId: __t.u64().name("payee_player_id"), + currency: __t.string(), + fundingAccountId: __t.u64().name("funding_account_id"), + total: __t.i64(), + remainderNumerator: __t.i64().name("remainder_numerator"), + remainderDenominator: __t.i64().name("remainder_denominator"), + guaranteedTotal: __t.i64().name("guaranteed_total"), + guaranteedRemainderNumerator: __t.i64().name("guaranteed_remainder_numerator"), + guaranteedRemainderDenominator: __t.i64().name("guaranteed_remainder_denominator"), + paidEscrow: __t.i64().name("paid_escrow"), + paidDirect: __t.i64().name("paid_direct"), + pulledExtras: __t.i64().name("pulled_extras"), + unclaimedSince: __t.option(__t.timestamp()).name("unclaimed_since"), + updatedAt: __t.timestamp().name("updated_at"), +}); diff --git a/common/bindings/brico-app/all_bounty_escrow_wallet_table.ts b/common/bindings/brico-app/all_bounty_escrow_wallet_table.ts new file mode 100644 index 00000000..dcd41313 --- /dev/null +++ b/common/bindings/brico-app/all_bounty_escrow_wallet_table.ts @@ -0,0 +1,15 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import {t as __t,} from "spacetimedb"; + +export default __t.row({ + id: __t.u64().primaryKey(), + payerAccountIdentity: __t.identity().name("payer_account_identity"), + currency: __t.string(), + fundingAccountId: __t.u64().name("funding_account_id"), + refundDue: __t.i64().name("refund_due"), + updatedAt: __t.timestamp().name("updated_at"), +}); diff --git a/common/bindings/brico-app/all_bounty_rule_table.ts b/common/bindings/brico-app/all_bounty_rule_table.ts index 053b13ef..0bdd5158 100644 --- a/common/bindings/brico-app/all_bounty_rule_table.ts +++ b/common/bindings/brico-app/all_bounty_rule_table.ts @@ -19,4 +19,5 @@ export default __t.row({ createdAt: __t.timestamp().name("created_at"), updatedAt: __t.timestamp().name("updated_at"), deletedAt: __t.option(__t.timestamp()).name("deleted_at"), + fundingLinkId: __t.option(__t.u64()).name("funding_link_id"), }); diff --git a/common/bindings/brico-app/all_craft_bounty_assignment_table.ts b/common/bindings/brico-app/all_craft_bounty_assignment_table.ts index b67c2eb2..04581fee 100644 --- a/common/bindings/brico-app/all_craft_bounty_assignment_table.ts +++ b/common/bindings/brico-app/all_craft_bounty_assignment_table.ts @@ -13,4 +13,5 @@ export default __t.row({ assignedByAccountIdentity: __t.identity().name("assigned_by_account_identity"), assignedAt: __t.timestamp().name("assigned_at"), updatedAt: __t.timestamp().name("updated_at"), + sticky: __t.bool(), }); diff --git a/common/bindings/brico-app/all_craft_bounty_escrow_table.ts b/common/bindings/brico-app/all_craft_bounty_escrow_table.ts new file mode 100644 index 00000000..ae9fc4e4 --- /dev/null +++ b/common/bindings/brico-app/all_craft_bounty_escrow_table.ts @@ -0,0 +1,26 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import {t as __t,} from "spacetimedb"; + +export default __t.row({ + id: __t.u64().primaryKey(), + craftId: __t.u64().name("craft_id"), + payerAccountIdentity: __t.identity().name("payer_account_identity"), + currency: __t.string(), + fundingLinkId: __t.u64().name("funding_link_id"), + fundingAccountId: __t.u64().name("funding_account_id"), + heldAmount: __t.i64().name("held_amount"), + releasedAmount: __t.i64().name("released_amount"), + ratioNumerator: __t.i64().name("ratio_numerator"), + ratioDenominator: __t.i64().name("ratio_denominator"), + effortCovered: __t.i64().name("effort_covered"), + effortPriced: __t.i64().name("effort_priced"), + accruedNumerator: __t.i64().name("accrued_numerator"), + accruedDenominator: __t.i64().name("accrued_denominator"), + heldAt: __t.timestamp().name("held_at"), + updatedAt: __t.timestamp().name("updated_at"), + closedAt: __t.option(__t.timestamp()).name("closed_at"), +}); diff --git a/common/bindings/brico-app/all_craft_bounty_funding_table.ts b/common/bindings/brico-app/all_craft_bounty_funding_table.ts new file mode 100644 index 00000000..2be6ddd1 --- /dev/null +++ b/common/bindings/brico-app/all_craft_bounty_funding_table.ts @@ -0,0 +1,21 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import {t as __t,} from "spacetimedb"; + +export default __t.row({ + craftId: __t.u64().primaryKey().name("craft_id"), + payerAccountIdentity: __t.identity().name("payer_account_identity"), + source: __t.string(), + ruleId: __t.option(__t.string()).name("rule_id"), + currency: __t.string(), + fundingLinkId: __t.option(__t.u64()).name("funding_link_id"), + ratioNumerator: __t.i64().name("ratio_numerator"), + ratioDenominator: __t.i64().name("ratio_denominator"), + state: __t.string(), + reason: __t.option(__t.string()), + transferId: __t.option(__t.u64()).name("transfer_id"), + updatedAt: __t.timestamp().name("updated_at"), +}); diff --git a/common/bindings/brico-app/all_craft_bounty_override_table.ts b/common/bindings/brico-app/all_craft_bounty_override_table.ts index 0ddecf79..90e0a25c 100644 --- a/common/bindings/brico-app/all_craft_bounty_override_table.ts +++ b/common/bindings/brico-app/all_craft_bounty_override_table.ts @@ -13,4 +13,5 @@ export default __t.row({ currency: __t.string(), private: __t.bool(), updatedAt: __t.timestamp().name("updated_at"), + fundingLinkId: __t.option(__t.u64()).name("funding_link_id"), }); diff --git a/common/bindings/brico-app/all_discord_funding_notify_target_table.ts b/common/bindings/brico-app/all_discord_funding_notify_target_table.ts new file mode 100644 index 00000000..5d4de8ea --- /dev/null +++ b/common/bindings/brico-app/all_discord_funding_notify_target_table.ts @@ -0,0 +1,20 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import {t as __t,} from "spacetimedb"; + +export default __t.row({ + id: __t.string().primaryKey(), + accountIdentity: __t.identity().name("account_identity"), + sinkId: __t.string().name("sink_id"), + fundingFailedTemplate: __t.option(__t.string()).name("funding_failed_template"), + payoutShortfallTemplate: __t.option(__t.string()).name("payout_shortfall_template"), + unclaimedTemplate: __t.option(__t.string()).name("unclaimed_template"), + mentionType: __t.option(__t.string()).name("mention_type"), + mentionId: __t.option(__t.string()).name("mention_id"), + mentionName: __t.option(__t.string()).name("mention_name"), + updatedAt: __t.timestamp().name("updated_at"), + deletedAt: __t.option(__t.timestamp()).name("deleted_at"), +}); diff --git a/common/bindings/brico-app/all_funding_notify_trigger_table.ts b/common/bindings/brico-app/all_funding_notify_trigger_table.ts new file mode 100644 index 00000000..db07bc1a --- /dev/null +++ b/common/bindings/brico-app/all_funding_notify_trigger_table.ts @@ -0,0 +1,21 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import {t as __t,} from "spacetimedb"; +import {NotifySinkRef,} from "./types"; + + +export default __t.row({ + id: __t.string().primaryKey(), + accountIdentity: __t.identity().name("account_identity"), + get sink() { + return NotifySinkRef; + }, + fundingFailed: __t.bool().name("funding_failed"), + payoutShortfall: __t.bool().name("payout_shortfall"), + unclaimed: __t.bool(), + updatedAt: __t.timestamp().name("updated_at"), + deletedAt: __t.option(__t.timestamp()).name("deleted_at"), +}); diff --git a/common/bindings/brico-app/all_integration_link_request_table.ts b/common/bindings/brico-app/all_integration_link_request_table.ts index baae7bea..95ec8b26 100644 --- a/common/bindings/brico-app/all_integration_link_request_table.ts +++ b/common/bindings/brico-app/all_integration_link_request_table.ts @@ -14,4 +14,9 @@ export default __t.row({ externalId: __t.option(__t.string()).name("external_id"), externalHandle: __t.option(__t.string()).name("external_handle"), createdAt: __t.timestamp().name("created_at"), + candidateAddress: __t.option(__t.string()).name("candidate_address"), + pendingReason: __t.option(__t.string()).name("pending_reason"), + unverified: __t.bool(), + matchedUsername: __t.option(__t.string()).name("matched_username"), + confirmed: __t.bool(), }); diff --git a/common/bindings/brico-app/all_private_craft_bounty_assignment_table.ts b/common/bindings/brico-app/all_private_craft_bounty_assignment_table.ts index b67c2eb2..04581fee 100644 --- a/common/bindings/brico-app/all_private_craft_bounty_assignment_table.ts +++ b/common/bindings/brico-app/all_private_craft_bounty_assignment_table.ts @@ -13,4 +13,5 @@ export default __t.row({ assignedByAccountIdentity: __t.identity().name("assigned_by_account_identity"), assignedAt: __t.timestamp().name("assigned_at"), updatedAt: __t.timestamp().name("updated_at"), + sticky: __t.bool(), }); diff --git a/common/bindings/brico-app/all_stelo_receiving_wallet_table.ts b/common/bindings/brico-app/all_stelo_receiving_wallet_table.ts new file mode 100644 index 00000000..a429967c --- /dev/null +++ b/common/bindings/brico-app/all_stelo_receiving_wallet_table.ts @@ -0,0 +1,13 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import {t as __t,} from "spacetimedb"; + +export default __t.row({ + id: __t.u64().primaryKey(), + accountIdentity: __t.identity().name("account_identity"), + currency: __t.string(), + linkId: __t.u64().name("link_id"), +}); diff --git a/common/bindings/brico-app/all_stelo_transfer_table.ts b/common/bindings/brico-app/all_stelo_transfer_table.ts new file mode 100644 index 00000000..5021a3a9 --- /dev/null +++ b/common/bindings/brico-app/all_stelo_transfer_table.ts @@ -0,0 +1,29 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import {t as __t,} from "spacetimedb"; +import {SteloTransferPurpose, SteloTransferState,} from "./types"; + + +export default __t.row({ + id: __t.u64().primaryKey(), + ref: __t.string(), + currency: __t.string(), + payerAccountIdentity: __t.identity().name("payer_account_identity"), + fromAccountId: __t.u64().name("from_account_id"), + toAccountId: __t.u64().name("to_account_id"), + amount: __t.i64(), + steloAmount: __t.u64().name("stelo_amount"), + get purpose() { + return SteloTransferPurpose; + }, + get state() { + return SteloTransferState; + }, + steloTransferId: __t.option(__t.u64()).name("stelo_transfer_id"), + failureReason: __t.option(__t.string()).name("failure_reason"), + createdAt: __t.timestamp().name("created_at"), + settledAt: __t.option(__t.timestamp()).name("settled_at"), +}); diff --git a/common/bindings/brico-app/all_stelo_wallet_status_table.ts b/common/bindings/brico-app/all_stelo_wallet_status_table.ts new file mode 100644 index 00000000..5f64f407 --- /dev/null +++ b/common/bindings/brico-app/all_stelo_wallet_status_table.ts @@ -0,0 +1,14 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import {t as __t,} from "spacetimedb"; + +export default __t.row({ + linkId: __t.u64().primaryKey().name("link_id"), + accountIdentity: __t.identity().name("account_identity"), + canPull: __t.bool().name("can_pull"), + verified: __t.bool(), + updatedAt: __t.timestamp().name("updated_at"), +}); diff --git a/common/bindings/brico-app/all_stelo_wallet_table.ts b/common/bindings/brico-app/all_stelo_wallet_table.ts new file mode 100644 index 00000000..9383815d --- /dev/null +++ b/common/bindings/brico-app/all_stelo_wallet_table.ts @@ -0,0 +1,12 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import {t as __t,} from "spacetimedb"; + +export default __t.row({ + linkId: __t.u64().primaryKey().name("link_id"), + accountIdentity: __t.identity().name("account_identity"), + currency: __t.string(), +}); diff --git a/common/bindings/brico-app/assign_craft_bounty_reducer.ts b/common/bindings/brico-app/assign_craft_bounty_reducer.ts index 2b4846e7..1d15f9fd 100644 --- a/common/bindings/brico-app/assign_craft_bounty_reducer.ts +++ b/common/bindings/brico-app/assign_craft_bounty_reducer.ts @@ -19,4 +19,5 @@ export default { get baselines() { return __t.array(AssignmentBaseline); }, + sticky: __t.bool(), }; diff --git a/common/bindings/brico-app/attach_discord_funding_target_reducer.ts b/common/bindings/brico-app/attach_discord_funding_target_reducer.ts new file mode 100644 index 00000000..11e9d479 --- /dev/null +++ b/common/bindings/brico-app/attach_discord_funding_target_reducer.ts @@ -0,0 +1,18 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import {t as __t,} from "spacetimedb"; + +export default { + id: __t.string(), + accountIdentity: __t.identity(), + discordSinkId: __t.string(), + fundingFailedTemplate: __t.string(), + payoutShortfallTemplate: __t.string(), + unclaimedTemplate: __t.string(), + mentionType: __t.option(__t.string()), + mentionId: __t.option(__t.string()), + mentionName: __t.option(__t.string()), +}; diff --git a/common/bindings/brico-app/cancel_integration_link_request_reducer.ts b/common/bindings/brico-app/cancel_integration_link_request_reducer.ts new file mode 100644 index 00000000..64686a8e --- /dev/null +++ b/common/bindings/brico-app/cancel_integration_link_request_reducer.ts @@ -0,0 +1,10 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import {t as __t,} from "spacetimedb"; + +export default { + code: __t.string(), +}; diff --git a/common/bindings/brico-app/clear_craft_bounty_funding_reducer.ts b/common/bindings/brico-app/clear_craft_bounty_funding_reducer.ts new file mode 100644 index 00000000..85db8016 --- /dev/null +++ b/common/bindings/brico-app/clear_craft_bounty_funding_reducer.ts @@ -0,0 +1,10 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import {t as __t,} from "spacetimedb"; + +export default { + craftId: __t.u64(), +}; diff --git a/common/bindings/brico-app/close_craft_escrow_reducer.ts b/common/bindings/brico-app/close_craft_escrow_reducer.ts new file mode 100644 index 00000000..85db8016 --- /dev/null +++ b/common/bindings/brico-app/close_craft_escrow_reducer.ts @@ -0,0 +1,10 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import {t as __t,} from "spacetimedb"; + +export default { + craftId: __t.u64(), +}; diff --git a/common/bindings/brico-app/confirm_integration_link_candidate_reducer.ts b/common/bindings/brico-app/confirm_integration_link_candidate_reducer.ts new file mode 100644 index 00000000..64686a8e --- /dev/null +++ b/common/bindings/brico-app/confirm_integration_link_candidate_reducer.ts @@ -0,0 +1,10 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import {t as __t,} from "spacetimedb"; + +export default { + code: __t.string(), +}; diff --git a/common/bindings/brico-app/detach_discord_funding_target_reducer.ts b/common/bindings/brico-app/detach_discord_funding_target_reducer.ts new file mode 100644 index 00000000..3814a918 --- /dev/null +++ b/common/bindings/brico-app/detach_discord_funding_target_reducer.ts @@ -0,0 +1,11 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import {t as __t,} from "spacetimedb"; + +export default { + id: __t.string(), + deletedAt: __t.timestamp(), +}; diff --git a/common/bindings/brico-app/detach_funding_notify_trigger_reducer.ts b/common/bindings/brico-app/detach_funding_notify_trigger_reducer.ts new file mode 100644 index 00000000..3814a918 --- /dev/null +++ b/common/bindings/brico-app/detach_funding_notify_trigger_reducer.ts @@ -0,0 +1,11 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import {t as __t,} from "spacetimedb"; + +export default { + id: __t.string(), + deletedAt: __t.timestamp(), +}; diff --git a/common/bindings/brico-app/fail_stelo_transfer_reducer.ts b/common/bindings/brico-app/fail_stelo_transfer_reducer.ts new file mode 100644 index 00000000..174f6d8f --- /dev/null +++ b/common/bindings/brico-app/fail_stelo_transfer_reducer.ts @@ -0,0 +1,11 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import {t as __t,} from "spacetimedb"; + +export default { + id: __t.u64(), + reason: __t.string(), +}; diff --git a/common/bindings/brico-app/index.ts b/common/bindings/brico-app/index.ts index edac0b28..20db927a 100644 --- a/common/bindings/brico-app/index.ts +++ b/common/bindings/brico-app/index.ts @@ -6,39 +6,46 @@ /* eslint-disable */ /* tslint:disable */ import { - convertToAccessorMap as __convertToAccessorMap, - DbConnectionBuilder as __DbConnectionBuilder, - type DbConnectionConfig as __DbConnectionConfig, - DbConnectionImpl as __DbConnectionImpl, - type ErrorContextInterface as __ErrorContextInterface, - type EventContextInterface as __EventContextInterface, - makeQueryBuilder as __makeQueryBuilder, - procedures as __procedures, - procedureSchema as __procedureSchema, - type QueryBuilder as __QueryBuilder, - type ReducerEventContextInterface as __ReducerEventContextInterface, - reducers as __reducers, - reducerSchema as __reducerSchema, - type RemoteModule as __RemoteModule, - schema as __schema, - SubscriptionBuilderImpl as __SubscriptionBuilderImpl, - type SubscriptionEventContextInterface as __SubscriptionEventContextInterface, - type SubscriptionHandleImpl as __SubscriptionHandleImpl, - table as __table, + convertToAccessorMap as __convertToAccessorMap, + DbConnectionBuilder as __DbConnectionBuilder, + type DbConnectionConfig as __DbConnectionConfig, + DbConnectionImpl as __DbConnectionImpl, + type ErrorContextInterface as __ErrorContextInterface, + type EventContextInterface as __EventContextInterface, + makeQueryBuilder as __makeQueryBuilder, + procedures as __procedures, + procedureSchema as __procedureSchema, + type QueryBuilder as __QueryBuilder, + type ReducerEventContextInterface as __ReducerEventContextInterface, + reducers as __reducers, + reducerSchema as __reducerSchema, + type RemoteModule as __RemoteModule, + schema as __schema, + SubscriptionBuilderImpl as __SubscriptionBuilderImpl, + type SubscriptionEventContextInterface as __SubscriptionEventContextInterface, + type SubscriptionHandleImpl as __SubscriptionHandleImpl, + table as __table, } from "spacetimedb"; // Import all table schema definitions import AllAccountRow from "./all_account_table"; import AllBountyEntitlementTotalRow from "./all_bounty_entitlement_total_table"; +import AllBountyEscrowJournalRow from "./all_bounty_escrow_journal_table"; +import AllBountyEscrowPoolRow from "./all_bounty_escrow_pool_table"; +import AllBountyEscrowWalletRow from "./all_bounty_escrow_wallet_table"; import AllBountyRuleRow from "./all_bounty_rule_table"; import AllCraftBountyAssignmentRow from "./all_craft_bounty_assignment_table"; import AllCraftBountyEntitlementRow from "./all_craft_bounty_entitlement_table"; +import AllCraftBountyEscrowRow from "./all_craft_bounty_escrow_table"; +import AllCraftBountyFundingRow from "./all_craft_bounty_funding_table"; import AllCraftBountyOverrideRow from "./all_craft_bounty_override_table"; import AllCraftFilterNotifyTriggerRow from "./all_craft_filter_notify_trigger_table"; +import AllDiscordFundingNotifyTargetRow from "./all_discord_funding_notify_target_table"; import AllDiscordGuildInstallRow from "./all_discord_guild_install_table"; import AllDiscordNotifySinkRow from "./all_discord_notify_sink_table"; import AllDiscordNotifyTargetRow from "./all_discord_notify_target_table"; import AllDiscordWatchDisplayRow from "./all_discord_watch_display_table"; +import AllFundingNotifyTriggerRow from "./all_funding_notify_trigger_table"; import AllIntegrationLinkRequestRow from "./all_integration_link_request_table"; import AllLinkedIntegrationRow from "./all_linked_integration_table"; import AllLoyaltyBonusTotalRow from "./all_loyalty_bonus_total_table"; @@ -46,16 +53,25 @@ import AllLoyaltyRewardRow from "./all_loyalty_reward_table"; import AllLoyaltyRuleRow from "./all_loyalty_rule_table"; import AllPrivateCraftBountyAssignmentRow from "./all_private_craft_bounty_assignment_table"; import AllSavedCraftFilterRow from "./all_saved_craft_filter_table"; +import AllSteloReceivingWalletRow from "./all_stelo_receiving_wallet_table"; +import AllSteloTransferRow from "./all_stelo_transfer_table"; +import AllSteloWalletStatusRow from "./all_stelo_wallet_status_table"; +import AllSteloWalletRow from "./all_stelo_wallet_table"; // Import all reducer arg schemas import AssignCraftBountyReducer from "./assign_craft_bounty_reducer"; +import AttachDiscordFundingTargetReducer from "./attach_discord_funding_target_reducer"; import AttachDiscordNotifySinkReducer from "./attach_discord_notify_sink_reducer"; import AttachDiscordNotifyTargetReducer from "./attach_discord_notify_target_reducer"; import AttachDiscordWatchDisplayReducer from "./attach_discord_watch_display_reducer"; import BeginIntegrationLinkForExternalReducer from "./begin_integration_link_for_external_reducer"; import BeginIntegrationLinkReducer from "./begin_integration_link_reducer"; +import CancelIntegrationLinkRequestReducer from "./cancel_integration_link_request_reducer"; import ClaimIntegrationLinkReducer from "./claim_integration_link_reducer"; +import ClearCraftBountyFundingReducer from "./clear_craft_bounty_funding_reducer"; import ClearCraftBountyReducer from "./clear_craft_bounty_reducer"; +import CloseCraftEscrowReducer from "./close_craft_escrow_reducer"; +import ConfirmIntegrationLinkCandidateReducer from "./confirm_integration_link_candidate_reducer"; // Import all procedure arg schemas import * as CreateSharedFiltersProcedure from "./create_shared_filters_procedure"; @@ -67,27 +83,36 @@ import DeleteLoyaltyRuleReducer from "./delete_loyalty_rule_reducer"; import DeleteNotificationReducer from "./delete_notification_reducer"; import DeleteSavedCraftFilterReducer from "./delete_saved_craft_filter_reducer"; import DetachCraftFilterNotifyTriggerReducer from "./detach_craft_filter_notify_trigger_reducer"; +import DetachDiscordFundingTargetReducer from "./detach_discord_funding_target_reducer"; import DetachDiscordNotifySinkReducer from "./detach_discord_notify_sink_reducer"; import DetachDiscordNotifyTargetReducer from "./detach_discord_notify_target_reducer"; import DetachDiscordWatchDisplayReducer from "./detach_discord_watch_display_reducer"; +import DetachFundingNotifyTriggerReducer from "./detach_funding_notify_trigger_reducer"; import EnsureAccountReducer from "./ensure_account_reducer"; +import FailSteloTransferReducer from "./fail_stelo_transfer_reducer"; import ImportHistoricalBountyLedgerReducer from "./import_historical_bounty_ledger_reducer"; import LinkDiscordViaSpacetimeAuthReducer from "./link_discord_via_spacetime_auth_reducer"; import LinkIntegrationReducer from "./link_integration_reducer"; import MarkAllNotificationsReadReducer from "./mark_all_notifications_read_reducer"; import MarkNotificationReadReducer from "./mark_notification_read_reducer"; +import MigrateNotificationsToV2Reducer from "./migrate_notifications_to_v_2_reducer"; import MyAccountRow from "./my_account_table"; +import MyBountyEscrowWalletRow from "./my_bounty_escrow_wallet_table"; import MyBountyPayoutRecordAsPayerRow from "./my_bounty_payout_record_as_payer_table"; import MyBountyPayoutRecordLogAsContributorRow from "./my_bounty_payout_record_log_as_contributor_table"; import MyBountyPayoutRecordLogAsPayerRow from "./my_bounty_payout_record_log_as_payer_table"; import MyBountyRuleRow from "./my_bounty_rule_table"; +import MyCraftBountyEscrowRow from "./my_craft_bounty_escrow_table"; +import MyCraftBountyFundingRow from "./my_craft_bounty_funding_table"; import MyCraftBountyOverrideRow from "./my_craft_bounty_override_table"; import MyCraftFilterNotifyTriggerRow from "./my_craft_filter_notify_trigger_table"; +import MyDiscordFundingNotifyTargetRow from "./my_discord_funding_notify_target_table"; import MyDiscordNotifySinkRow from "./my_discord_notify_sink_table"; import MyDiscordNotifyTargetRow from "./my_discord_notify_target_table"; import MyDiscordWatchDisplayRow from "./my_discord_watch_display_table"; import MyEntitlementsAsContributorRow from "./my_entitlements_as_contributor_table"; import MyEntitlementsAsPayerRow from "./my_entitlements_as_payer_table"; +import MyFundingNotifyTriggerRow from "./my_funding_notify_trigger_table"; import MyIntegrationLinkRequestRow from "./my_integration_link_request_table"; import MyLinkedIntegrationRow from "./my_linked_integration_table"; import MyLoyaltyBonusTotalRow from "./my_loyalty_bonus_total_table"; @@ -96,22 +121,46 @@ import MyLoyaltyRuleRow from "./my_loyalty_rule_table"; import MyNotificationRow from "./my_notification_table"; import MyPrivateCraftBountyAssignmentRow from "./my_private_craft_bounty_assignment_table"; import MySavedCraftFilterRow from "./my_saved_craft_filter_table"; +import MySteloReceivingWalletRow from "./my_stelo_receiving_wallet_table"; +import MySteloTransferRow from "./my_stelo_transfer_table"; +import MySteloWalletStatusRow from "./my_stelo_wallet_status_table"; +import MySteloWalletRow from "./my_stelo_wallet_table"; import NoteIntegrationLinkExternalReducer from "./note_integration_link_external_reducer"; +import NoteIntegrationLinkStatusReducer from "./note_integration_link_status_reducer"; import PostCraftNotificationReducer from "./post_craft_notification_reducer"; +import PostFundingNotificationReducer from "./post_funding_notification_reducer"; +import RaiseCraftEscrowFromRefundDueReducer from "./raise_craft_escrow_from_refund_due_reducer"; import * as ReadSharedFiltersProcedure from "./read_shared_filters_procedure"; +import RecordBountyPaymentForReducer from "./record_bounty_payment_for_reducer"; import RecordBountyPaymentReducer from "./record_bounty_payment_reducer"; +import RecordSteloTransferIntentReducer from "./record_stelo_transfer_intent_reducer"; import RegisterServicePrincipalReducer from "./register_service_principal_reducer"; +import RegisterSteloWalletReducer from "./register_stelo_wallet_reducer"; +import ReleaseEscrowOverlapReducer from "./release_escrow_overlap_reducer"; import ReorderBountyRulesReducer from "./reorder_bounty_rules_reducer"; import RevokeServicePrincipalReducer from "./revoke_service_principal_reducer"; +import SetCraftBountyFundingReducer from "./set_craft_bounty_funding_reducer"; import SetDiscordWatchDisplayMessageReducer from "./set_discord_watch_display_message_reducer"; import SetDisplayNameReducer from "./set_display_name_reducer"; +import SetSteloCurrencyPolicyReducer from "./set_stelo_currency_policy_reducer"; +import SetSteloPayoutScheduleReducer from "./set_stelo_payout_schedule_reducer"; +import SetSteloReceivingWalletReducer from "./set_stelo_receiving_wallet_reducer"; +import SetSteloWalletStatusReducer from "./set_stelo_wallet_status_reducer"; +import SettleSteloTransferReducer from "./settle_stelo_transfer_reducer"; +import SettleUnclaimedPayeeReducer from "./settle_unclaimed_payee_reducer"; +import SteloCurrencyPolicyRow from "./stelo_currency_policy_table"; +import SteloPayoutScheduleRow from "./stelo_payout_schedule_table"; +import SubmitIntegrationLinkCandidateReducer from "./submit_integration_link_candidate_reducer"; +import SyncEscrowPoolUnclaimedReducer from "./sync_escrow_pool_unclaimed_reducer"; import UnlinkIntegrationReducer from "./unlink_integration_reducer"; import UpsertBountyRuleReducer from "./upsert_bounty_rule_reducer"; import UpsertCraftBountyEntitlementReducer from "./upsert_craft_bounty_entitlement_reducer"; import UpsertCraftBountyOverrideReducer from "./upsert_craft_bounty_override_reducer"; import UpsertCraftFilterNotifyTriggerReducer from "./upsert_craft_filter_notify_trigger_reducer"; +import UpsertDiscordFundingTargetTemplateReducer from "./upsert_discord_funding_target_template_reducer"; import UpsertDiscordGuildInstallReducer from "./upsert_discord_guild_install_reducer"; import UpsertDiscordNotifyTargetTemplateReducer from "./upsert_discord_notify_target_template_reducer"; +import UpsertFundingNotifyTriggerReducer from "./upsert_funding_notify_trigger_reducer"; import UpsertLoyaltyBonusTotalReducer from "./upsert_loyalty_bonus_total_reducer"; import UpsertLoyaltyRewardReducer from "./upsert_loyalty_reward_reducer"; import UpsertLoyaltyRuleReducer from "./upsert_loyalty_rule_reducer"; @@ -121,6 +170,28 @@ import UpsertSavedCraftFilterReducer from "./upsert_saved_craft_filter_reducer"; /** The schema information for all tables in this module. This is defined the same was as the tables would have been defined in the server. */ const tablesSchema = __schema({ + steloCurrencyPolicy: __table({ + name: 'stelo_currency_policy', + indexes: [ + { accessor: 'currency', name: 'stelo_currency_policy_currency_idx_btree', algorithm: 'btree', columns: [ + 'currency', + ] }, + ], + constraints: [ + { name: 'stelo_currency_policy_currency_key', constraint: 'unique', columns: ['currency'] }, + ], + }, SteloCurrencyPolicyRow), + steloPayoutSchedule: __table({ + name: 'stelo_payout_schedule', + indexes: [ + { accessor: 'id', name: 'stelo_payout_schedule_id_idx_btree', algorithm: 'btree', columns: [ + 'id', + ] }, + ], + constraints: [ + { name: 'stelo_payout_schedule_id_key', constraint: 'unique', columns: ['id'] }, + ], + }, SteloPayoutScheduleRow), allAccount: __table({ name: 'all_account', indexes: [ @@ -135,6 +206,27 @@ const tablesSchema = __schema({ constraints: [ ], }, AllBountyEntitlementTotalRow), + allBountyEscrowJournal: __table({ + name: 'all_bounty_escrow_journal', + indexes: [ + ], + constraints: [ + ], + }, AllBountyEscrowJournalRow), + allBountyEscrowPool: __table({ + name: 'all_bounty_escrow_pool', + indexes: [ + ], + constraints: [ + ], + }, AllBountyEscrowPoolRow), + allBountyEscrowWallet: __table({ + name: 'all_bounty_escrow_wallet', + indexes: [ + ], + constraints: [ + ], + }, AllBountyEscrowWalletRow), allBountyRule: __table({ name: 'all_bounty_rule', indexes: [ @@ -156,6 +248,20 @@ const tablesSchema = __schema({ constraints: [ ], }, AllCraftBountyEntitlementRow), + allCraftBountyEscrow: __table({ + name: 'all_craft_bounty_escrow', + indexes: [ + ], + constraints: [ + ], + }, AllCraftBountyEscrowRow), + allCraftBountyFunding: __table({ + name: 'all_craft_bounty_funding', + indexes: [ + ], + constraints: [ + ], + }, AllCraftBountyFundingRow), allCraftBountyOverride: __table({ name: 'all_craft_bounty_override', indexes: [ @@ -170,6 +276,13 @@ const tablesSchema = __schema({ constraints: [ ], }, AllCraftFilterNotifyTriggerRow), + allDiscordFundingNotifyTarget: __table({ + name: 'all_discord_funding_notify_target', + indexes: [ + ], + constraints: [ + ], + }, AllDiscordFundingNotifyTargetRow), allDiscordGuildInstall: __table({ name: 'all_discord_guild_install', indexes: [ @@ -198,6 +311,13 @@ const tablesSchema = __schema({ constraints: [ ], }, AllDiscordWatchDisplayRow), + allFundingNotifyTrigger: __table({ + name: 'all_funding_notify_trigger', + indexes: [ + ], + constraints: [ + ], + }, AllFundingNotifyTriggerRow), allIntegrationLinkRequest: __table({ name: 'all_integration_link_request', indexes: [ @@ -247,6 +367,34 @@ const tablesSchema = __schema({ constraints: [ ], }, AllSavedCraftFilterRow), + allSteloReceivingWallet: __table({ + name: 'all_stelo_receiving_wallet', + indexes: [ + ], + constraints: [ + ], + }, AllSteloReceivingWalletRow), + allSteloTransfer: __table({ + name: 'all_stelo_transfer', + indexes: [ + ], + constraints: [ + ], + }, AllSteloTransferRow), + allSteloWallet: __table({ + name: 'all_stelo_wallet', + indexes: [ + ], + constraints: [ + ], + }, AllSteloWalletRow), + allSteloWalletStatus: __table({ + name: 'all_stelo_wallet_status', + indexes: [ + ], + constraints: [ + ], + }, AllSteloWalletStatusRow), myAccount: __table({ name: 'my_account', indexes: [ @@ -254,6 +402,13 @@ const tablesSchema = __schema({ constraints: [ ], }, MyAccountRow), + myBountyEscrowWallet: __table({ + name: 'my_bounty_escrow_wallet', + indexes: [ + ], + constraints: [ + ], + }, MyBountyEscrowWalletRow), myBountyPayoutRecordAsPayer: __table({ name: 'my_bounty_payout_record_as_payer', indexes: [ @@ -282,6 +437,20 @@ const tablesSchema = __schema({ constraints: [ ], }, MyBountyRuleRow), + myCraftBountyEscrow: __table({ + name: 'my_craft_bounty_escrow', + indexes: [ + ], + constraints: [ + ], + }, MyCraftBountyEscrowRow), + myCraftBountyFunding: __table({ + name: 'my_craft_bounty_funding', + indexes: [ + ], + constraints: [ + ], + }, MyCraftBountyFundingRow), myCraftBountyOverride: __table({ name: 'my_craft_bounty_override', indexes: [ @@ -296,6 +465,13 @@ const tablesSchema = __schema({ constraints: [ ], }, MyCraftFilterNotifyTriggerRow), + myDiscordFundingNotifyTarget: __table({ + name: 'my_discord_funding_notify_target', + indexes: [ + ], + constraints: [ + ], + }, MyDiscordFundingNotifyTargetRow), myDiscordNotifySink: __table({ name: 'my_discord_notify_sink', indexes: [ @@ -331,6 +507,13 @@ const tablesSchema = __schema({ constraints: [ ], }, MyEntitlementsAsPayerRow), + myFundingNotifyTrigger: __table({ + name: 'my_funding_notify_trigger', + indexes: [ + ], + constraints: [ + ], + }, MyFundingNotifyTriggerRow), myIntegrationLinkRequest: __table({ name: 'my_integration_link_request', indexes: [ @@ -387,18 +570,51 @@ const tablesSchema = __schema({ constraints: [ ], }, MySavedCraftFilterRow), + mySteloReceivingWallet: __table({ + name: 'my_stelo_receiving_wallet', + indexes: [ + ], + constraints: [ + ], + }, MySteloReceivingWalletRow), + mySteloTransfer: __table({ + name: 'my_stelo_transfer', + indexes: [ + ], + constraints: [ + ], + }, MySteloTransferRow), + mySteloWallet: __table({ + name: 'my_stelo_wallet', + indexes: [ + ], + constraints: [ + ], + }, MySteloWalletRow), + mySteloWalletStatus: __table({ + name: 'my_stelo_wallet_status', + indexes: [ + ], + constraints: [ + ], + }, MySteloWalletStatusRow), }); /** The schema information for all reducers in this module. This is defined the same way as the reducers would have been defined in the server, except the body of the reducer is omitted in code generation. */ const reducersSchema = __reducers( __reducerSchema("assign_craft_bounty", AssignCraftBountyReducer), + __reducerSchema("attach_discord_funding_target", AttachDiscordFundingTargetReducer), __reducerSchema("attach_discord_notify_sink", AttachDiscordNotifySinkReducer), __reducerSchema("attach_discord_notify_target", AttachDiscordNotifyTargetReducer), __reducerSchema("attach_discord_watch_display", AttachDiscordWatchDisplayReducer), __reducerSchema("begin_integration_link", BeginIntegrationLinkReducer), __reducerSchema("begin_integration_link_for_external", BeginIntegrationLinkForExternalReducer), + __reducerSchema("cancel_integration_link_request", CancelIntegrationLinkRequestReducer), __reducerSchema("claim_integration_link", ClaimIntegrationLinkReducer), __reducerSchema("clear_craft_bounty", ClearCraftBountyReducer), + __reducerSchema("clear_craft_bounty_funding", ClearCraftBountyFundingReducer), + __reducerSchema("close_craft_escrow", CloseCraftEscrowReducer), + __reducerSchema("confirm_integration_link_candidate", ConfirmIntegrationLinkCandidateReducer), __reducerSchema("delete_bounty_rule", DeleteBountyRuleReducer), __reducerSchema("delete_craft_bounty_override", DeleteCraftBountyOverrideReducer), __reducerSchema("delete_loyalty_bonus_total", DeleteLoyaltyBonusTotalReducer), @@ -407,30 +623,52 @@ const reducersSchema = __reducers( __reducerSchema("delete_notification", DeleteNotificationReducer), __reducerSchema("delete_saved_craft_filter", DeleteSavedCraftFilterReducer), __reducerSchema("detach_craft_filter_notify_trigger", DetachCraftFilterNotifyTriggerReducer), + __reducerSchema("detach_discord_funding_target", DetachDiscordFundingTargetReducer), __reducerSchema("detach_discord_notify_sink", DetachDiscordNotifySinkReducer), __reducerSchema("detach_discord_notify_target", DetachDiscordNotifyTargetReducer), __reducerSchema("detach_discord_watch_display", DetachDiscordWatchDisplayReducer), + __reducerSchema("detach_funding_notify_trigger", DetachFundingNotifyTriggerReducer), __reducerSchema("ensure_account", EnsureAccountReducer), + __reducerSchema("fail_stelo_transfer", FailSteloTransferReducer), __reducerSchema("import_historical_bounty_ledger", ImportHistoricalBountyLedgerReducer), __reducerSchema("link_discord_via_spacetime_auth", LinkDiscordViaSpacetimeAuthReducer), __reducerSchema("link_integration", LinkIntegrationReducer), __reducerSchema("mark_all_notifications_read", MarkAllNotificationsReadReducer), __reducerSchema("mark_notification_read", MarkNotificationReadReducer), + __reducerSchema("migrate_notifications_to_v_2", MigrateNotificationsToV2Reducer), __reducerSchema("note_integration_link_external", NoteIntegrationLinkExternalReducer), + __reducerSchema("note_integration_link_status", NoteIntegrationLinkStatusReducer), __reducerSchema("post_craft_notification", PostCraftNotificationReducer), + __reducerSchema("post_funding_notification", PostFundingNotificationReducer), + __reducerSchema("raise_craft_escrow_from_refund_due", RaiseCraftEscrowFromRefundDueReducer), __reducerSchema("record_bounty_payment", RecordBountyPaymentReducer), + __reducerSchema("record_bounty_payment_for", RecordBountyPaymentForReducer), + __reducerSchema("record_stelo_transfer_intent", RecordSteloTransferIntentReducer), __reducerSchema("register_service_principal", RegisterServicePrincipalReducer), + __reducerSchema("register_stelo_wallet", RegisterSteloWalletReducer), + __reducerSchema("release_escrow_overlap", ReleaseEscrowOverlapReducer), __reducerSchema("reorder_bounty_rules", ReorderBountyRulesReducer), __reducerSchema("revoke_service_principal", RevokeServicePrincipalReducer), + __reducerSchema("set_craft_bounty_funding", SetCraftBountyFundingReducer), __reducerSchema("set_discord_watch_display_message", SetDiscordWatchDisplayMessageReducer), __reducerSchema("set_display_name", SetDisplayNameReducer), + __reducerSchema("set_stelo_currency_policy", SetSteloCurrencyPolicyReducer), + __reducerSchema("set_stelo_payout_schedule", SetSteloPayoutScheduleReducer), + __reducerSchema("set_stelo_receiving_wallet", SetSteloReceivingWalletReducer), + __reducerSchema("set_stelo_wallet_status", SetSteloWalletStatusReducer), + __reducerSchema("settle_stelo_transfer", SettleSteloTransferReducer), + __reducerSchema("settle_unclaimed_payee", SettleUnclaimedPayeeReducer), + __reducerSchema("submit_integration_link_candidate", SubmitIntegrationLinkCandidateReducer), + __reducerSchema("sync_escrow_pool_unclaimed", SyncEscrowPoolUnclaimedReducer), __reducerSchema("unlink_integration", UnlinkIntegrationReducer), __reducerSchema("upsert_bounty_rule", UpsertBountyRuleReducer), __reducerSchema("upsert_craft_bounty_entitlement", UpsertCraftBountyEntitlementReducer), __reducerSchema("upsert_craft_bounty_override", UpsertCraftBountyOverrideReducer), __reducerSchema("upsert_craft_filter_notify_trigger", UpsertCraftFilterNotifyTriggerReducer), + __reducerSchema("upsert_discord_funding_target_template", UpsertDiscordFundingTargetTemplateReducer), __reducerSchema("upsert_discord_guild_install", UpsertDiscordGuildInstallReducer), __reducerSchema("upsert_discord_notify_target_template", UpsertDiscordNotifyTargetTemplateReducer), + __reducerSchema("upsert_funding_notify_trigger", UpsertFundingNotifyTriggerReducer), __reducerSchema("upsert_loyalty_bonus_total", UpsertLoyaltyBonusTotalReducer), __reducerSchema("upsert_loyalty_reward", UpsertLoyaltyRewardReducer), __reducerSchema("upsert_loyalty_rule", UpsertLoyaltyRuleReducer), @@ -443,22 +681,69 @@ const proceduresSchema = __procedures( __procedureSchema("read_shared_filters", ReadSharedFiltersProcedure.params, ReadSharedFiltersProcedure.returnType), ); +type __SchemaWithTableAccessorAliases = Omit & { + tables: typeof tablesSchema.schemaType.tables & { + /** @deprecated Use `steloCurrencyPolicy` instead. This alias will be removed in the next major version. */ + readonly "stelo_currency_policy": Omit & { readonly accessorName: "stelo_currency_policy" }; + /** @deprecated Use `steloPayoutSchedule` instead. This alias will be removed in the next major version. */ + readonly "stelo_payout_schedule": Omit & { readonly accessorName: "stelo_payout_schedule" }; + }; +}; + /** The remote SpacetimeDB module schema, both runtime and type information. */ const REMOTE_MODULE = { versionInfo: { cliVersion: "2.10.0" as const, }, - tables: tablesSchema.schemaType.tables, + tables: tablesSchema.schemaType.tables as __SchemaWithTableAccessorAliases["tables"], reducers: reducersSchema.reducersType.reducers, ...proceduresSchema, } satisfies __RemoteModule< - typeof tablesSchema.schemaType, + __SchemaWithTableAccessorAliases, typeof reducersSchema.reducersType, typeof proceduresSchema >; +const tableAccessorAliases = { + "stelo_currency_policy": "steloCurrencyPolicy", + "stelo_payout_schedule": "steloPayoutSchedule", +} as const; + +function __withTableAccessorAliases(target: T, freeze = false): T { + const out = Object.create(Object.getPrototypeOf(target)) as T & Record; + Object.defineProperties(out, Object.getOwnPropertyDescriptors(target)); + for (const [deprecatedAccessor, targetAccessor] of Object.entries(tableAccessorAliases)) { + if (deprecatedAccessor in out) { + continue; + } + Object.defineProperty(out, deprecatedAccessor, { + enumerable: true, + configurable: false, + get: () => out[targetAccessor], + }); + } + return freeze ? Object.freeze(out) : out; +} + +type __DbViewBase = __DbConnectionImpl["db"]; +export type DbView = __DbViewBase & { + /** @deprecated Use `steloCurrencyPolicy` instead. This alias will be removed in the next major version. */ + readonly "stelo_currency_policy": __DbViewBase["steloCurrencyPolicy"]; + /** @deprecated Use `steloPayoutSchedule` instead. This alias will be removed in the next major version. */ + readonly "stelo_payout_schedule": __DbViewBase["steloPayoutSchedule"]; +}; + +type __TablesBase = __QueryBuilder; +export type Tables = __TablesBase & { + /** @deprecated Use `steloCurrencyPolicy` instead. This alias will be removed in the next major version. */ + readonly "stelo_currency_policy": __TablesBase["steloCurrencyPolicy"]; + /** @deprecated Use `steloPayoutSchedule` instead. This alias will be removed in the next major version. */ + readonly "stelo_payout_schedule": __TablesBase["steloPayoutSchedule"]; +}; + /** The tables available in this remote SpacetimeDB module. Each table reference doubles as a query builder. */ -export const tables: __QueryBuilder = __makeQueryBuilder(tablesSchema.schemaType); +const tablesBase: __TablesBase = __makeQueryBuilder(tablesSchema.schemaType); +export const tables: Tables = __withTableAccessorAliases(tablesBase, true) as Tables; /** The reducers available in this remote SpacetimeDB module. */ export const reducers = __convertToAccessorMap(reducersSchema.reducersType.reducers); @@ -467,13 +752,13 @@ export const reducers = __convertToAccessorMap(reducersSchema.reducersType.reduc export const procedures = __convertToAccessorMap(proceduresSchema.procedures); /** The context type returned in callbacks for all possible events. */ -export type EventContext = __EventContextInterface; +export type EventContext = Omit<__EventContextInterface, "db"> & { db: DbView }; /** The context type returned in callbacks for reducer events. */ -export type ReducerEventContext = __ReducerEventContextInterface; +export type ReducerEventContext = Omit<__ReducerEventContextInterface, "db"> & { db: DbView }; /** The context type returned in callbacks for subscription events. */ -export type SubscriptionEventContext = __SubscriptionEventContextInterface; +export type SubscriptionEventContext = Omit<__SubscriptionEventContextInterface, "db"> & { db: DbView }; /** The context type returned in callbacks for error events. */ -export type ErrorContext = __ErrorContextInterface; +export type ErrorContext = Omit<__ErrorContextInterface, "db"> & { db: DbView }; /** The subscription handle type to manage active subscriptions created from a {@link SubscriptionBuilder}. */ export type SubscriptionHandle = __SubscriptionHandleImpl; @@ -485,6 +770,13 @@ export class DbConnectionBuilder extends __DbConnectionBuilder {} /** The typed database connection to manage connections to the remote SpacetimeDB instance. This class has type information specific to the generated module. */ export class DbConnection extends __DbConnectionImpl { + declare db: DbView; + + constructor(config: __DbConnectionConfig) { + super(config); + this.db = __withTableAccessorAliases(this.db) as DbView; + } + /** Creates a new {@link DbConnectionBuilder} to configure and connect to the remote SpacetimeDB instance. */ static builder = (): DbConnectionBuilder => { return new DbConnectionBuilder(REMOTE_MODULE, (config: __DbConnectionConfig) => new DbConnection(config)); diff --git a/common/bindings/brico-app/migrate_notifications_to_v_2_reducer.ts b/common/bindings/brico-app/migrate_notifications_to_v_2_reducer.ts new file mode 100644 index 00000000..922587a7 --- /dev/null +++ b/common/bindings/brico-app/migrate_notifications_to_v_2_reducer.ts @@ -0,0 +1,7 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ + +export default {}; diff --git a/common/bindings/brico-app/my_bounty_escrow_wallet_table.ts b/common/bindings/brico-app/my_bounty_escrow_wallet_table.ts new file mode 100644 index 00000000..dcd41313 --- /dev/null +++ b/common/bindings/brico-app/my_bounty_escrow_wallet_table.ts @@ -0,0 +1,15 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import {t as __t,} from "spacetimedb"; + +export default __t.row({ + id: __t.u64().primaryKey(), + payerAccountIdentity: __t.identity().name("payer_account_identity"), + currency: __t.string(), + fundingAccountId: __t.u64().name("funding_account_id"), + refundDue: __t.i64().name("refund_due"), + updatedAt: __t.timestamp().name("updated_at"), +}); diff --git a/common/bindings/brico-app/my_bounty_payout_record_log_as_contributor_table.ts b/common/bindings/brico-app/my_bounty_payout_record_log_as_contributor_table.ts index 22fffef5..3b23c489 100644 --- a/common/bindings/brico-app/my_bounty_payout_record_log_as_contributor_table.ts +++ b/common/bindings/brico-app/my_bounty_payout_record_log_as_contributor_table.ts @@ -3,7 +3,12 @@ /* eslint-disable */ /* tslint:disable */ -import {t as __t,} from "spacetimedb"; +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; export default __t.row({ id: __t.u64().primaryKey(), @@ -13,4 +18,5 @@ export default __t.row({ currency: __t.string(), amount: __t.i64(), createdAt: __t.timestamp().name("created_at"), + source: __t.string(), }); diff --git a/common/bindings/brico-app/my_bounty_payout_record_log_as_payer_table.ts b/common/bindings/brico-app/my_bounty_payout_record_log_as_payer_table.ts index 22fffef5..3b23c489 100644 --- a/common/bindings/brico-app/my_bounty_payout_record_log_as_payer_table.ts +++ b/common/bindings/brico-app/my_bounty_payout_record_log_as_payer_table.ts @@ -3,7 +3,12 @@ /* eslint-disable */ /* tslint:disable */ -import {t as __t,} from "spacetimedb"; +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; export default __t.row({ id: __t.u64().primaryKey(), @@ -13,4 +18,5 @@ export default __t.row({ currency: __t.string(), amount: __t.i64(), createdAt: __t.timestamp().name("created_at"), + source: __t.string(), }); diff --git a/common/bindings/brico-app/my_bounty_rule_table.ts b/common/bindings/brico-app/my_bounty_rule_table.ts index 053b13ef..39661937 100644 --- a/common/bindings/brico-app/my_bounty_rule_table.ts +++ b/common/bindings/brico-app/my_bounty_rule_table.ts @@ -3,8 +3,15 @@ /* eslint-disable */ /* tslint:disable */ -import {t as __t,} from "spacetimedb"; -import {BountyRuleValue,} from "./types"; +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; +import { + BountyRuleValue, +} from "./types"; export default __t.row({ @@ -19,4 +26,5 @@ export default __t.row({ createdAt: __t.timestamp().name("created_at"), updatedAt: __t.timestamp().name("updated_at"), deletedAt: __t.option(__t.timestamp()).name("deleted_at"), + fundingLinkId: __t.option(__t.u64()).name("funding_link_id"), }); diff --git a/common/bindings/brico-app/my_craft_bounty_escrow_table.ts b/common/bindings/brico-app/my_craft_bounty_escrow_table.ts new file mode 100644 index 00000000..63cf3435 --- /dev/null +++ b/common/bindings/brico-app/my_craft_bounty_escrow_table.ts @@ -0,0 +1,31 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default __t.row({ + id: __t.u64().primaryKey(), + craftId: __t.u64().name("craft_id"), + payerAccountIdentity: __t.identity().name("payer_account_identity"), + currency: __t.string(), + fundingLinkId: __t.u64().name("funding_link_id"), + fundingAccountId: __t.u64().name("funding_account_id"), + heldAmount: __t.i64().name("held_amount"), + releasedAmount: __t.i64().name("released_amount"), + ratioNumerator: __t.i64().name("ratio_numerator"), + ratioDenominator: __t.i64().name("ratio_denominator"), + effortCovered: __t.i64().name("effort_covered"), + effortPriced: __t.i64().name("effort_priced"), + accruedNumerator: __t.i64().name("accrued_numerator"), + accruedDenominator: __t.i64().name("accrued_denominator"), + heldAt: __t.timestamp().name("held_at"), + updatedAt: __t.timestamp().name("updated_at"), + closedAt: __t.option(__t.timestamp()).name("closed_at"), +}); diff --git a/common/bindings/brico-app/my_craft_bounty_funding_table.ts b/common/bindings/brico-app/my_craft_bounty_funding_table.ts new file mode 100644 index 00000000..d77f6e83 --- /dev/null +++ b/common/bindings/brico-app/my_craft_bounty_funding_table.ts @@ -0,0 +1,26 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default __t.row({ + craftId: __t.u64().primaryKey().name("craft_id"), + payerAccountIdentity: __t.identity().name("payer_account_identity"), + source: __t.string(), + ruleId: __t.option(__t.string()).name("rule_id"), + currency: __t.string(), + fundingLinkId: __t.option(__t.u64()).name("funding_link_id"), + ratioNumerator: __t.i64().name("ratio_numerator"), + ratioDenominator: __t.i64().name("ratio_denominator"), + state: __t.string(), + reason: __t.option(__t.string()), + transferId: __t.option(__t.u64()).name("transfer_id"), + updatedAt: __t.timestamp().name("updated_at"), +}); diff --git a/common/bindings/brico-app/my_craft_bounty_override_table.ts b/common/bindings/brico-app/my_craft_bounty_override_table.ts index 0ddecf79..6a23e2b8 100644 --- a/common/bindings/brico-app/my_craft_bounty_override_table.ts +++ b/common/bindings/brico-app/my_craft_bounty_override_table.ts @@ -3,7 +3,12 @@ /* eslint-disable */ /* tslint:disable */ -import {t as __t,} from "spacetimedb"; +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; export default __t.row({ craftId: __t.u64().primaryKey().name("craft_id"), @@ -13,4 +18,5 @@ export default __t.row({ currency: __t.string(), private: __t.bool(), updatedAt: __t.timestamp().name("updated_at"), + fundingLinkId: __t.option(__t.u64()).name("funding_link_id"), }); diff --git a/common/bindings/brico-app/my_craft_filter_notify_trigger_table.ts b/common/bindings/brico-app/my_craft_filter_notify_trigger_table.ts index 4ba5d987..9a13db93 100644 --- a/common/bindings/brico-app/my_craft_filter_notify_trigger_table.ts +++ b/common/bindings/brico-app/my_craft_filter_notify_trigger_table.ts @@ -3,8 +3,15 @@ /* eslint-disable */ /* tslint:disable */ -import {t as __t,} from "spacetimedb"; -import {NotifySinkRef,} from "./types"; +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; +import { + NotifySinkRef, +} from "./types"; export default __t.row({ diff --git a/common/bindings/brico-app/my_discord_funding_notify_target_table.ts b/common/bindings/brico-app/my_discord_funding_notify_target_table.ts new file mode 100644 index 00000000..da402eda --- /dev/null +++ b/common/bindings/brico-app/my_discord_funding_notify_target_table.ts @@ -0,0 +1,25 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default __t.row({ + id: __t.string().primaryKey(), + accountIdentity: __t.identity().name("account_identity"), + sinkId: __t.string().name("sink_id"), + fundingFailedTemplate: __t.option(__t.string()).name("funding_failed_template"), + payoutShortfallTemplate: __t.option(__t.string()).name("payout_shortfall_template"), + unclaimedTemplate: __t.option(__t.string()).name("unclaimed_template"), + mentionType: __t.option(__t.string()).name("mention_type"), + mentionId: __t.option(__t.string()).name("mention_id"), + mentionName: __t.option(__t.string()).name("mention_name"), + updatedAt: __t.timestamp().name("updated_at"), + deletedAt: __t.option(__t.timestamp()).name("deleted_at"), +}); diff --git a/common/bindings/brico-app/my_discord_notify_sink_table.ts b/common/bindings/brico-app/my_discord_notify_sink_table.ts index 43357c0c..df11a088 100644 --- a/common/bindings/brico-app/my_discord_notify_sink_table.ts +++ b/common/bindings/brico-app/my_discord_notify_sink_table.ts @@ -3,7 +3,12 @@ /* eslint-disable */ /* tslint:disable */ -import {t as __t,} from "spacetimedb"; +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; export default __t.row({ id: __t.string().primaryKey(), diff --git a/common/bindings/brico-app/my_discord_notify_target_table.ts b/common/bindings/brico-app/my_discord_notify_target_table.ts index 5cf562ac..5167ca59 100644 --- a/common/bindings/brico-app/my_discord_notify_target_table.ts +++ b/common/bindings/brico-app/my_discord_notify_target_table.ts @@ -3,7 +3,12 @@ /* eslint-disable */ /* tslint:disable */ -import {t as __t,} from "spacetimedb"; +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; export default __t.row({ id: __t.string().primaryKey(), diff --git a/common/bindings/brico-app/my_discord_watch_display_table.ts b/common/bindings/brico-app/my_discord_watch_display_table.ts index 8bf3e272..1724d4a9 100644 --- a/common/bindings/brico-app/my_discord_watch_display_table.ts +++ b/common/bindings/brico-app/my_discord_watch_display_table.ts @@ -3,8 +3,15 @@ /* eslint-disable */ /* tslint:disable */ -import {t as __t,} from "spacetimedb"; -import {DiscordWatchDisplayContent,} from "./types"; +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; +import { + DiscordWatchDisplayContent, +} from "./types"; export default __t.row({ diff --git a/common/bindings/brico-app/my_entitlements_as_contributor_table.ts b/common/bindings/brico-app/my_entitlements_as_contributor_table.ts index e7e87480..4baaed67 100644 --- a/common/bindings/brico-app/my_entitlements_as_contributor_table.ts +++ b/common/bindings/brico-app/my_entitlements_as_contributor_table.ts @@ -3,7 +3,12 @@ /* eslint-disable */ /* tslint:disable */ -import {t as __t,} from "spacetimedb"; +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; export default __t.row({ id: __t.string().primaryKey(), diff --git a/common/bindings/brico-app/my_entitlements_as_payer_table.ts b/common/bindings/brico-app/my_entitlements_as_payer_table.ts index 95a440a0..7984d020 100644 --- a/common/bindings/brico-app/my_entitlements_as_payer_table.ts +++ b/common/bindings/brico-app/my_entitlements_as_payer_table.ts @@ -3,7 +3,12 @@ /* eslint-disable */ /* tslint:disable */ -import {t as __t,} from "spacetimedb"; +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; export default __t.row({ id: __t.string().primaryKey(), @@ -13,5 +18,6 @@ export default __t.row({ currency: __t.string(), effortTotal: __t.i64().name("effort_total"), earnedTotal: __t.i64().name("earned_total"), + guaranteedTotal: __t.i64().name("guaranteed_total"), paidTotal: __t.i64().name("paid_total"), }); diff --git a/common/bindings/brico-app/my_funding_notify_trigger_table.ts b/common/bindings/brico-app/my_funding_notify_trigger_table.ts new file mode 100644 index 00000000..a9871d8f --- /dev/null +++ b/common/bindings/brico-app/my_funding_notify_trigger_table.ts @@ -0,0 +1,28 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; +import { + NotifySinkRef, +} from "./types"; + + +export default __t.row({ + id: __t.string().primaryKey(), + accountIdentity: __t.identity().name("account_identity"), + get sink() { + return NotifySinkRef; + }, + fundingFailed: __t.bool().name("funding_failed"), + payoutShortfall: __t.bool().name("payout_shortfall"), + unclaimed: __t.bool(), + updatedAt: __t.timestamp().name("updated_at"), + deletedAt: __t.option(__t.timestamp()).name("deleted_at"), +}); diff --git a/common/bindings/brico-app/my_integration_link_request_table.ts b/common/bindings/brico-app/my_integration_link_request_table.ts index baae7bea..4569e558 100644 --- a/common/bindings/brico-app/my_integration_link_request_table.ts +++ b/common/bindings/brico-app/my_integration_link_request_table.ts @@ -3,7 +3,12 @@ /* eslint-disable */ /* tslint:disable */ -import {t as __t,} from "spacetimedb"; +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; export default __t.row({ id: __t.u64().primaryKey(), @@ -14,4 +19,9 @@ export default __t.row({ externalId: __t.option(__t.string()).name("external_id"), externalHandle: __t.option(__t.string()).name("external_handle"), createdAt: __t.timestamp().name("created_at"), + candidateAddress: __t.option(__t.string()).name("candidate_address"), + pendingReason: __t.option(__t.string()).name("pending_reason"), + unverified: __t.bool(), + matchedUsername: __t.option(__t.string()).name("matched_username"), + confirmed: __t.bool(), }); diff --git a/common/bindings/brico-app/my_linked_integration_table.ts b/common/bindings/brico-app/my_linked_integration_table.ts index cebd363f..abd39643 100644 --- a/common/bindings/brico-app/my_linked_integration_table.ts +++ b/common/bindings/brico-app/my_linked_integration_table.ts @@ -3,7 +3,12 @@ /* eslint-disable */ /* tslint:disable */ -import {t as __t,} from "spacetimedb"; +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; export default __t.row({ id: __t.u64().primaryKey(), diff --git a/common/bindings/brico-app/my_loyalty_bonus_total_table.ts b/common/bindings/brico-app/my_loyalty_bonus_total_table.ts index 30d14bf1..5925e0dc 100644 --- a/common/bindings/brico-app/my_loyalty_bonus_total_table.ts +++ b/common/bindings/brico-app/my_loyalty_bonus_total_table.ts @@ -3,7 +3,12 @@ /* eslint-disable */ /* tslint:disable */ -import {t as __t,} from "spacetimedb"; +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; export default __t.row({ id: __t.u64().primaryKey(), diff --git a/common/bindings/brico-app/my_loyalty_reward_table.ts b/common/bindings/brico-app/my_loyalty_reward_table.ts index 0afc2810..82baaf66 100644 --- a/common/bindings/brico-app/my_loyalty_reward_table.ts +++ b/common/bindings/brico-app/my_loyalty_reward_table.ts @@ -3,7 +3,12 @@ /* eslint-disable */ /* tslint:disable */ -import {t as __t,} from "spacetimedb"; +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; export default __t.row({ id: __t.u64().primaryKey(), diff --git a/common/bindings/brico-app/my_loyalty_rule_table.ts b/common/bindings/brico-app/my_loyalty_rule_table.ts index 0f14b32f..71d1497a 100644 --- a/common/bindings/brico-app/my_loyalty_rule_table.ts +++ b/common/bindings/brico-app/my_loyalty_rule_table.ts @@ -3,8 +3,15 @@ /* eslint-disable */ /* tslint:disable */ -import {t as __t,} from "spacetimedb"; -import {LoyaltyRuleSpec,} from "./types"; +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; +import { + LoyaltyRuleSpec, +} from "./types"; export default __t.row({ diff --git a/common/bindings/brico-app/my_notification_table.ts b/common/bindings/brico-app/my_notification_table.ts index eb47f3f7..2b2b9492 100644 --- a/common/bindings/brico-app/my_notification_table.ts +++ b/common/bindings/brico-app/my_notification_table.ts @@ -3,16 +3,17 @@ /* eslint-disable */ /* tslint:disable */ -import {t as __t,} from "spacetimedb"; -import {NotificationPayload,} from "./types"; - +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; export default __t.row({ id: __t.u64().primaryKey(), accountIdentity: __t.identity().name("account_identity"), - get payload() { - return NotificationPayload; - }, + payloadJson: __t.string().name("payload_json"), createdAt: __t.timestamp().name("created_at"), readAt: __t.option(__t.timestamp()).name("read_at"), }); diff --git a/common/bindings/brico-app/my_private_craft_bounty_assignment_table.ts b/common/bindings/brico-app/my_private_craft_bounty_assignment_table.ts index b67c2eb2..ed46d0a2 100644 --- a/common/bindings/brico-app/my_private_craft_bounty_assignment_table.ts +++ b/common/bindings/brico-app/my_private_craft_bounty_assignment_table.ts @@ -3,7 +3,12 @@ /* eslint-disable */ /* tslint:disable */ -import {t as __t,} from "spacetimedb"; +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; export default __t.row({ craftId: __t.u64().primaryKey().name("craft_id"), @@ -13,4 +18,5 @@ export default __t.row({ assignedByAccountIdentity: __t.identity().name("assigned_by_account_identity"), assignedAt: __t.timestamp().name("assigned_at"), updatedAt: __t.timestamp().name("updated_at"), + sticky: __t.bool(), }); diff --git a/common/bindings/brico-app/my_saved_craft_filter_table.ts b/common/bindings/brico-app/my_saved_craft_filter_table.ts index e154fd25..97c93329 100644 --- a/common/bindings/brico-app/my_saved_craft_filter_table.ts +++ b/common/bindings/brico-app/my_saved_craft_filter_table.ts @@ -3,7 +3,12 @@ /* eslint-disable */ /* tslint:disable */ -import {t as __t,} from "spacetimedb"; +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; export default __t.row({ id: __t.string().primaryKey(), diff --git a/common/bindings/brico-app/my_stelo_receiving_wallet_table.ts b/common/bindings/brico-app/my_stelo_receiving_wallet_table.ts new file mode 100644 index 00000000..e108289e --- /dev/null +++ b/common/bindings/brico-app/my_stelo_receiving_wallet_table.ts @@ -0,0 +1,18 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default __t.row({ + id: __t.u64().primaryKey(), + accountIdentity: __t.identity().name("account_identity"), + currency: __t.string(), + linkId: __t.u64().name("link_id"), +}); diff --git a/common/bindings/brico-app/my_stelo_transfer_table.ts b/common/bindings/brico-app/my_stelo_transfer_table.ts new file mode 100644 index 00000000..cfb6b5c2 --- /dev/null +++ b/common/bindings/brico-app/my_stelo_transfer_table.ts @@ -0,0 +1,37 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; +import { + SteloTransferPurpose, + SteloTransferState, +} from "./types"; + + +export default __t.row({ + id: __t.u64().primaryKey(), + ref: __t.string(), + currency: __t.string(), + payerAccountIdentity: __t.identity().name("payer_account_identity"), + fromAccountId: __t.u64().name("from_account_id"), + toAccountId: __t.u64().name("to_account_id"), + amount: __t.i64(), + steloAmount: __t.u64().name("stelo_amount"), + get purpose() { + return SteloTransferPurpose; + }, + get state() { + return SteloTransferState; + }, + steloTransferId: __t.option(__t.u64()).name("stelo_transfer_id"), + failureReason: __t.option(__t.string()).name("failure_reason"), + createdAt: __t.timestamp().name("created_at"), + settledAt: __t.option(__t.timestamp()).name("settled_at"), +}); diff --git a/common/bindings/brico-app/my_stelo_wallet_status_table.ts b/common/bindings/brico-app/my_stelo_wallet_status_table.ts new file mode 100644 index 00000000..5bec9882 --- /dev/null +++ b/common/bindings/brico-app/my_stelo_wallet_status_table.ts @@ -0,0 +1,19 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default __t.row({ + linkId: __t.u64().primaryKey().name("link_id"), + accountIdentity: __t.identity().name("account_identity"), + canPull: __t.bool().name("can_pull"), + verified: __t.bool(), + updatedAt: __t.timestamp().name("updated_at"), +}); diff --git a/common/bindings/brico-app/my_stelo_wallet_table.ts b/common/bindings/brico-app/my_stelo_wallet_table.ts new file mode 100644 index 00000000..cece4efb --- /dev/null +++ b/common/bindings/brico-app/my_stelo_wallet_table.ts @@ -0,0 +1,17 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default __t.row({ + linkId: __t.u64().primaryKey().name("link_id"), + accountIdentity: __t.identity().name("account_identity"), + currency: __t.string(), +}); diff --git a/common/bindings/brico-app/note_integration_link_external_reducer.ts b/common/bindings/brico-app/note_integration_link_external_reducer.ts index 25890d77..d2b98b17 100644 --- a/common/bindings/brico-app/note_integration_link_external_reducer.ts +++ b/common/bindings/brico-app/note_integration_link_external_reducer.ts @@ -3,7 +3,12 @@ /* eslint-disable */ /* tslint:disable */ -import {t as __t,} from "spacetimedb"; +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; export default { code: __t.string(), diff --git a/common/bindings/brico-app/note_integration_link_status_reducer.ts b/common/bindings/brico-app/note_integration_link_status_reducer.ts new file mode 100644 index 00000000..aa28d6a1 --- /dev/null +++ b/common/bindings/brico-app/note_integration_link_status_reducer.ts @@ -0,0 +1,17 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default { + code: __t.string(), + reason: __t.string(), + matchedUsername: __t.option(__t.string()), +}; diff --git a/common/bindings/brico-app/post_craft_notification_reducer.ts b/common/bindings/brico-app/post_craft_notification_reducer.ts index dcb90aac..125349bb 100644 --- a/common/bindings/brico-app/post_craft_notification_reducer.ts +++ b/common/bindings/brico-app/post_craft_notification_reducer.ts @@ -3,9 +3,16 @@ /* eslint-disable */ /* tslint:disable */ -import {t as __t,} from "spacetimedb"; +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; -import {CraftNotificationPayload,} from "./types"; +import { + CraftNotificationPayload, +} from "./types"; export default { accountIdentity: __t.identity(), diff --git a/common/bindings/brico-app/post_funding_notification_reducer.ts b/common/bindings/brico-app/post_funding_notification_reducer.ts new file mode 100644 index 00000000..c88ecca9 --- /dev/null +++ b/common/bindings/brico-app/post_funding_notification_reducer.ts @@ -0,0 +1,22 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +import { + FundingNotificationPayload, +} from "./types"; + +export default { + accountIdentity: __t.identity(), + get payload() { + return FundingNotificationPayload; + }, +}; diff --git a/common/bindings/brico-app/raise_craft_escrow_from_refund_due_reducer.ts b/common/bindings/brico-app/raise_craft_escrow_from_refund_due_reducer.ts new file mode 100644 index 00000000..2d258e40 --- /dev/null +++ b/common/bindings/brico-app/raise_craft_escrow_from_refund_due_reducer.ts @@ -0,0 +1,17 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default { + craftId: __t.u64(), + ratioNumerator: __t.i64(), + ratioDenominator: __t.i64(), +}; diff --git a/common/bindings/brico-app/read_shared_filters_procedure.ts b/common/bindings/brico-app/read_shared_filters_procedure.ts index a426eddb..bfab3517 100644 --- a/common/bindings/brico-app/read_shared_filters_procedure.ts +++ b/common/bindings/brico-app/read_shared_filters_procedure.ts @@ -3,9 +3,16 @@ /* eslint-disable */ /* tslint:disable */ -import {t as __t,} from "spacetimedb"; +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; -import {ReadSharedFiltersResult,} from "./types"; +import { + ReadSharedFiltersResult, +} from "./types"; export const params = { code: __t.string(), diff --git a/common/bindings/brico-app/record_bounty_payment_for_reducer.ts b/common/bindings/brico-app/record_bounty_payment_for_reducer.ts new file mode 100644 index 00000000..c66481a5 --- /dev/null +++ b/common/bindings/brico-app/record_bounty_payment_for_reducer.ts @@ -0,0 +1,13 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import {t as __t,} from "spacetimedb"; + +export default { + payerAccountIdentity: __t.identity(), + payeePlayerId: __t.u64(), + currency: __t.string(), + delta: __t.i64(), +}; diff --git a/common/bindings/brico-app/record_bounty_payment_reducer.ts b/common/bindings/brico-app/record_bounty_payment_reducer.ts index 563c4a0d..0644bed2 100644 --- a/common/bindings/brico-app/record_bounty_payment_reducer.ts +++ b/common/bindings/brico-app/record_bounty_payment_reducer.ts @@ -3,7 +3,12 @@ /* eslint-disable */ /* tslint:disable */ -import {t as __t,} from "spacetimedb"; +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; export default { payeePlayerId: __t.u64(), diff --git a/common/bindings/brico-app/record_stelo_transfer_intent_reducer.ts b/common/bindings/brico-app/record_stelo_transfer_intent_reducer.ts new file mode 100644 index 00000000..95c2dcc1 --- /dev/null +++ b/common/bindings/brico-app/record_stelo_transfer_intent_reducer.ts @@ -0,0 +1,28 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +import { + SteloTransferPurpose, +} from "./types"; + +export default { + refPrefix: __t.string(), + currency: __t.string(), + payerAccountIdentity: __t.identity(), + fromAccountId: __t.u64(), + toAccountId: __t.u64(), + amount: __t.i64(), + steloAmount: __t.u64(), + get purpose() { + return SteloTransferPurpose; + }, +}; diff --git a/common/bindings/brico-app/register_service_principal_reducer.ts b/common/bindings/brico-app/register_service_principal_reducer.ts index e4592d13..435d27c8 100644 --- a/common/bindings/brico-app/register_service_principal_reducer.ts +++ b/common/bindings/brico-app/register_service_principal_reducer.ts @@ -3,7 +3,12 @@ /* eslint-disable */ /* tslint:disable */ -import {t as __t,} from "spacetimedb"; +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; export default { identity: __t.identity(), diff --git a/common/bindings/brico-app/register_stelo_wallet_reducer.ts b/common/bindings/brico-app/register_stelo_wallet_reducer.ts new file mode 100644 index 00000000..9e94b2d8 --- /dev/null +++ b/common/bindings/brico-app/register_stelo_wallet_reducer.ts @@ -0,0 +1,16 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default { + linkId: __t.u64(), + currency: __t.string(), +}; diff --git a/common/bindings/brico-app/release_escrow_overlap_reducer.ts b/common/bindings/brico-app/release_escrow_overlap_reducer.ts new file mode 100644 index 00000000..b5bd7d0f --- /dev/null +++ b/common/bindings/brico-app/release_escrow_overlap_reducer.ts @@ -0,0 +1,17 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default { + payerAccountIdentity: __t.identity(), + payeePlayerId: __t.u64(), + currency: __t.string(), +}; diff --git a/common/bindings/brico-app/reorder_bounty_rules_reducer.ts b/common/bindings/brico-app/reorder_bounty_rules_reducer.ts index de0b6572..dea04456 100644 --- a/common/bindings/brico-app/reorder_bounty_rules_reducer.ts +++ b/common/bindings/brico-app/reorder_bounty_rules_reducer.ts @@ -3,7 +3,12 @@ /* eslint-disable */ /* tslint:disable */ -import {t as __t,} from "spacetimedb"; +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; export default { orderedIds: __t.array(__t.string()), diff --git a/common/bindings/brico-app/revoke_service_principal_reducer.ts b/common/bindings/brico-app/revoke_service_principal_reducer.ts index ec27a61e..e39846ca 100644 --- a/common/bindings/brico-app/revoke_service_principal_reducer.ts +++ b/common/bindings/brico-app/revoke_service_principal_reducer.ts @@ -3,7 +3,12 @@ /* eslint-disable */ /* tslint:disable */ -import {t as __t,} from "spacetimedb"; +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; export default { identity: __t.identity(), diff --git a/common/bindings/brico-app/set_craft_bounty_funding_reducer.ts b/common/bindings/brico-app/set_craft_bounty_funding_reducer.ts new file mode 100644 index 00000000..c6d94d91 --- /dev/null +++ b/common/bindings/brico-app/set_craft_bounty_funding_reducer.ts @@ -0,0 +1,25 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default { + craftId: __t.u64(), + payerAccountIdentity: __t.identity(), + source: __t.string(), + ruleId: __t.option(__t.string()), + currency: __t.string(), + fundingLinkId: __t.option(__t.u64()), + ratioNumerator: __t.i64(), + ratioDenominator: __t.i64(), + state: __t.string(), + reason: __t.option(__t.string()), + transferId: __t.option(__t.u64()), +}; diff --git a/common/bindings/brico-app/set_discord_watch_display_message_reducer.ts b/common/bindings/brico-app/set_discord_watch_display_message_reducer.ts index 9df01942..7a8cd2af 100644 --- a/common/bindings/brico-app/set_discord_watch_display_message_reducer.ts +++ b/common/bindings/brico-app/set_discord_watch_display_message_reducer.ts @@ -3,7 +3,12 @@ /* eslint-disable */ /* tslint:disable */ -import {t as __t,} from "spacetimedb"; +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; export default { id: __t.string(), diff --git a/common/bindings/brico-app/set_display_name_reducer.ts b/common/bindings/brico-app/set_display_name_reducer.ts index dd81a8dd..d935d885 100644 --- a/common/bindings/brico-app/set_display_name_reducer.ts +++ b/common/bindings/brico-app/set_display_name_reducer.ts @@ -3,7 +3,12 @@ /* eslint-disable */ /* tslint:disable */ -import {t as __t,} from "spacetimedb"; +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; export default { displayName: __t.option(__t.string()), diff --git a/common/bindings/brico-app/set_stelo_currency_policy_reducer.ts b/common/bindings/brico-app/set_stelo_currency_policy_reducer.ts new file mode 100644 index 00000000..4700c3d2 --- /dev/null +++ b/common/bindings/brico-app/set_stelo_currency_policy_reducer.ts @@ -0,0 +1,18 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default { + currency: __t.string(), + action: __t.string(), + rollTo: __t.option(__t.string()), + afterDays: __t.u32(), +}; diff --git a/common/bindings/brico-app/set_stelo_payout_schedule_reducer.ts b/common/bindings/brico-app/set_stelo_payout_schedule_reducer.ts new file mode 100644 index 00000000..eca4b774 --- /dev/null +++ b/common/bindings/brico-app/set_stelo_payout_schedule_reducer.ts @@ -0,0 +1,16 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default { + intervalMicros: __t.i64(), + lastSweepAt: __t.timestamp(), +}; diff --git a/common/bindings/brico-app/set_stelo_receiving_wallet_reducer.ts b/common/bindings/brico-app/set_stelo_receiving_wallet_reducer.ts new file mode 100644 index 00000000..6cf42a1c --- /dev/null +++ b/common/bindings/brico-app/set_stelo_receiving_wallet_reducer.ts @@ -0,0 +1,16 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default { + currency: __t.string(), + linkId: __t.u64(), +}; diff --git a/common/bindings/brico-app/set_stelo_wallet_status_reducer.ts b/common/bindings/brico-app/set_stelo_wallet_status_reducer.ts new file mode 100644 index 00000000..abfa20a6 --- /dev/null +++ b/common/bindings/brico-app/set_stelo_wallet_status_reducer.ts @@ -0,0 +1,17 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default { + linkId: __t.u64(), + canPull: __t.bool(), + verified: __t.bool(), +}; diff --git a/common/bindings/brico-app/settle_stelo_transfer_reducer.ts b/common/bindings/brico-app/settle_stelo_transfer_reducer.ts new file mode 100644 index 00000000..8c0ca817 --- /dev/null +++ b/common/bindings/brico-app/settle_stelo_transfer_reducer.ts @@ -0,0 +1,16 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default { + id: __t.u64(), + steloTransferId: __t.u64(), +}; diff --git a/common/bindings/brico-app/settle_unclaimed_payee_reducer.ts b/common/bindings/brico-app/settle_unclaimed_payee_reducer.ts new file mode 100644 index 00000000..27e2e289 --- /dev/null +++ b/common/bindings/brico-app/settle_unclaimed_payee_reducer.ts @@ -0,0 +1,19 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default { + payerAccountIdentity: __t.identity(), + payeePlayerId: __t.u64(), + currency: __t.string(), + fundingAccountId: __t.u64(), + rollTo: __t.option(__t.string()), +}; diff --git a/common/bindings/brico-app/stelo_currency_policy_table.ts b/common/bindings/brico-app/stelo_currency_policy_table.ts new file mode 100644 index 00000000..5ff19759 --- /dev/null +++ b/common/bindings/brico-app/stelo_currency_policy_table.ts @@ -0,0 +1,18 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default __t.row({ + currency: __t.string().primaryKey(), + action: __t.string(), + rollTo: __t.option(__t.string()).name("roll_to"), + afterDays: __t.u32().name("after_days"), +}); diff --git a/common/bindings/brico-app/stelo_payout_schedule_table.ts b/common/bindings/brico-app/stelo_payout_schedule_table.ts new file mode 100644 index 00000000..ed41817d --- /dev/null +++ b/common/bindings/brico-app/stelo_payout_schedule_table.ts @@ -0,0 +1,17 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default __t.row({ + id: __t.u8().primaryKey(), + intervalMicros: __t.i64().name("interval_micros"), + lastSweepAt: __t.timestamp().name("last_sweep_at"), +}); diff --git a/common/bindings/brico-app/submit_integration_link_candidate_reducer.ts b/common/bindings/brico-app/submit_integration_link_candidate_reducer.ts new file mode 100644 index 00000000..d6156b06 --- /dev/null +++ b/common/bindings/brico-app/submit_integration_link_candidate_reducer.ts @@ -0,0 +1,17 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default { + code: __t.string(), + candidate: __t.string(), + unverified: __t.bool(), +}; diff --git a/common/bindings/brico-app/sync_escrow_pool_unclaimed_reducer.ts b/common/bindings/brico-app/sync_escrow_pool_unclaimed_reducer.ts new file mode 100644 index 00000000..65c13968 --- /dev/null +++ b/common/bindings/brico-app/sync_escrow_pool_unclaimed_reducer.ts @@ -0,0 +1,18 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default { + payerAccountIdentity: __t.identity(), + payeePlayerId: __t.u64(), + currency: __t.string(), + fundingAccountId: __t.u64(), +}; diff --git a/common/bindings/brico-app/types.ts b/common/bindings/brico-app/types.ts index 61ba404c..3ccf3237 100644 --- a/common/bindings/brico-app/types.ts +++ b/common/bindings/brico-app/types.ts @@ -21,6 +21,15 @@ export type AllAccount = __Infer; export const AllBountyEntitlementTotal = __t.object("AllBountyEntitlementTotal", {}); export type AllBountyEntitlementTotal = __Infer; +export const AllBountyEscrowJournal = __t.object("AllBountyEscrowJournal", {}); +export type AllBountyEscrowJournal = __Infer; + +export const AllBountyEscrowPool = __t.object("AllBountyEscrowPool", {}); +export type AllBountyEscrowPool = __Infer; + +export const AllBountyEscrowWallet = __t.object("AllBountyEscrowWallet", {}); +export type AllBountyEscrowWallet = __Infer; + export const AllBountyRule = __t.object("AllBountyRule", {}); export type AllBountyRule = __Infer; @@ -30,12 +39,21 @@ export type AllCraftBountyAssignment = __Infer; export const AllCraftBountyEntitlement = __t.object("AllCraftBountyEntitlement", {}); export type AllCraftBountyEntitlement = __Infer; +export const AllCraftBountyEscrow = __t.object("AllCraftBountyEscrow", {}); +export type AllCraftBountyEscrow = __Infer; + +export const AllCraftBountyFunding = __t.object("AllCraftBountyFunding", {}); +export type AllCraftBountyFunding = __Infer; + export const AllCraftBountyOverride = __t.object("AllCraftBountyOverride", {}); export type AllCraftBountyOverride = __Infer; export const AllCraftFilterNotifyTrigger = __t.object("AllCraftFilterNotifyTrigger", {}); export type AllCraftFilterNotifyTrigger = __Infer; +export const AllDiscordFundingNotifyTarget = __t.object("AllDiscordFundingNotifyTarget", {}); +export type AllDiscordFundingNotifyTarget = __Infer; + export const AllDiscordGuildInstall = __t.object("AllDiscordGuildInstall", {}); export type AllDiscordGuildInstall = __Infer; @@ -48,6 +66,9 @@ export type AllDiscordNotifyTarget = __Infer; export const AllDiscordWatchDisplay = __t.object("AllDiscordWatchDisplay", {}); export type AllDiscordWatchDisplay = __Infer; +export const AllFundingNotifyTrigger = __t.object("AllFundingNotifyTrigger", {}); +export type AllFundingNotifyTrigger = __Infer; + export const AllIntegrationLinkRequest = __t.object("AllIntegrationLinkRequest", {}); export type AllIntegrationLinkRequest = __Infer; @@ -69,6 +90,18 @@ export type AllPrivateCraftBountyAssignment = __Infer; +export const AllSteloReceivingWallet = __t.object("AllSteloReceivingWallet", {}); +export type AllSteloReceivingWallet = __Infer; + +export const AllSteloTransfer = __t.object("AllSteloTransfer", {}); +export type AllSteloTransfer = __Infer; + +export const AllSteloWallet = __t.object("AllSteloWallet", {}); +export type AllSteloWallet = __Infer; + +export const AllSteloWalletStatus = __t.object("AllSteloWalletStatus", {}); +export type AllSteloWalletStatus = __Infer; + export const AssignmentBaseline = __t.object("AssignmentBaseline", { playerId: __t.u64(), effort: __t.i64(), @@ -88,6 +121,50 @@ export const BountyEntitlementTotal = __t.object("BountyEntitlementTotal", { }); export type BountyEntitlementTotal = __Infer; +export const BountyEscrowJournal = __t.object("BountyEscrowJournal", { + id: __t.u64(), + payerAccountIdentity: __t.identity(), + currency: __t.string(), + kind: __t.string(), + amount: __t.i64(), + fundingAccountId: __t.u64(), + craftId: __t.option(__t.u64()), + payeePlayerId: __t.option(__t.u64()), + transferId: __t.u64(), + createdAt: __t.timestamp(), +}); +export type BountyEscrowJournal = __Infer; + +export const BountyEscrowPool = __t.object("BountyEscrowPool", { + id: __t.u64(), + payerAccountIdentity: __t.identity(), + payeePlayerId: __t.u64(), + currency: __t.string(), + fundingAccountId: __t.u64(), + total: __t.i64(), + remainderNumerator: __t.i64(), + remainderDenominator: __t.i64(), + guaranteedTotal: __t.i64(), + guaranteedRemainderNumerator: __t.i64(), + guaranteedRemainderDenominator: __t.i64(), + paidEscrow: __t.i64(), + paidDirect: __t.i64(), + pulledExtras: __t.i64(), + unclaimedSince: __t.option(__t.timestamp()), + updatedAt: __t.timestamp(), +}); +export type BountyEscrowPool = __Infer; + +export const BountyEscrowWallet = __t.object("BountyEscrowWallet", { + id: __t.u64(), + payerAccountIdentity: __t.identity(), + currency: __t.string(), + fundingAccountId: __t.u64(), + refundDue: __t.i64(), + updatedAt: __t.timestamp(), +}); +export type BountyEscrowWallet = __Infer; + export const BountyPayoutLogRow = __t.object("BountyPayoutLogRow", { id: __t.u64(), payerAccountIdentity: __t.identity(), @@ -96,6 +173,7 @@ export const BountyPayoutLogRow = __t.object("BountyPayoutLogRow", { currency: __t.string(), amount: __t.i64(), createdAt: __t.timestamp(), + source: __t.string(), }); export type BountyPayoutLogRow = __Infer; @@ -116,6 +194,7 @@ export const BountyPayoutRecordLog = __t.object("BountyPayoutRecordLog", { currency: __t.string(), amount: __t.i64(), createdAt: __t.timestamp(), + source: __t.string(), }); export type BountyPayoutRecordLog = __Infer; @@ -131,6 +210,7 @@ export const BountyRule = __t.object("BountyRule", { createdAt: __t.timestamp(), updatedAt: __t.timestamp(), deletedAt: __t.option(__t.timestamp()), + fundingLinkId: __t.option(__t.u64()), }); export type BountyRule = __Infer; @@ -171,6 +251,7 @@ export const CraftBountyAssignment = __t.object("CraftBountyAssignment", { assignedByAccountIdentity: __t.identity(), assignedAt: __t.timestamp(), updatedAt: __t.timestamp(), + sticky: __t.bool(), }); export type CraftBountyAssignment = __Infer; @@ -184,6 +265,43 @@ export const CraftBountyEntitlement = __t.object("CraftBountyEntitlement", { }); export type CraftBountyEntitlement = __Infer; +export const CraftBountyEscrow = __t.object("CraftBountyEscrow", { + id: __t.u64(), + craftId: __t.u64(), + payerAccountIdentity: __t.identity(), + currency: __t.string(), + fundingLinkId: __t.u64(), + fundingAccountId: __t.u64(), + heldAmount: __t.i64(), + releasedAmount: __t.i64(), + ratioNumerator: __t.i64(), + ratioDenominator: __t.i64(), + effortCovered: __t.i64(), + effortPriced: __t.i64(), + accruedNumerator: __t.i64(), + accruedDenominator: __t.i64(), + heldAt: __t.timestamp(), + updatedAt: __t.timestamp(), + closedAt: __t.option(__t.timestamp()), +}); +export type CraftBountyEscrow = __Infer; + +export const CraftBountyFunding = __t.object("CraftBountyFunding", { + craftId: __t.u64(), + payerAccountIdentity: __t.identity(), + source: __t.string(), + ruleId: __t.option(__t.string()), + currency: __t.string(), + fundingLinkId: __t.option(__t.u64()), + ratioNumerator: __t.i64(), + ratioDenominator: __t.i64(), + state: __t.string(), + reason: __t.option(__t.string()), + transferId: __t.option(__t.u64()), + updatedAt: __t.timestamp(), +}); +export type CraftBountyFunding = __Infer; + export const CraftBountyOverride = __t.object("CraftBountyOverride", { craftId: __t.u64(), accountIdentity: __t.identity(), @@ -192,6 +310,7 @@ export const CraftBountyOverride = __t.object("CraftBountyOverride", { currency: __t.string(), private: __t.bool(), updatedAt: __t.timestamp(), + fundingLinkId: __t.option(__t.u64()), }); export type CraftBountyOverride = __Infer; @@ -241,6 +360,7 @@ export const CraftPrivateBountyAssignment = __t.object("CraftPrivateBountyAssign assignedByAccountIdentity: __t.identity(), assignedAt: __t.timestamp(), updatedAt: __t.timestamp(), + sticky: __t.bool(), }); export type CraftPrivateBountyAssignment = __Infer; @@ -249,6 +369,21 @@ export const CreateSharedFiltersResult = __t.object("CreateSharedFiltersResult", }); export type CreateSharedFiltersResult = __Infer; +export const DiscordFundingNotifyTarget = __t.object("DiscordFundingNotifyTarget", { + id: __t.string(), + accountIdentity: __t.identity(), + sinkId: __t.string(), + fundingFailedTemplate: __t.option(__t.string()), + payoutShortfallTemplate: __t.option(__t.string()), + unclaimedTemplate: __t.option(__t.string()), + mentionType: __t.option(__t.string()), + mentionId: __t.option(__t.string()), + mentionName: __t.option(__t.string()), + updatedAt: __t.timestamp(), + deletedAt: __t.option(__t.timestamp()), +}); +export type DiscordFundingNotifyTarget = __Infer; + export const DiscordGuildInstall = __t.object("DiscordGuildInstall", { guildId: __t.string(), commandMode: __t.string(), @@ -350,6 +485,32 @@ export const FlatBountyValue = __t.object("FlatBountyValue", { }); export type FlatBountyValue = __Infer; +export const FundingNotificationPayload = __t.object("FundingNotificationPayload", { + kind: __t.string(), + role: __t.string(), + currency: __t.string(), + craftId: __t.option(__t.u64()), + amount: __t.option(__t.i64()), + reason: __t.option(__t.string()), + payerName: __t.option(__t.string()), + payeeName: __t.option(__t.string()), +}); +export type FundingNotificationPayload = __Infer; + +export const FundingNotifyTrigger = __t.object("FundingNotifyTrigger", { + id: __t.string(), + accountIdentity: __t.identity(), + get sink() { + return NotifySinkRef; + }, + fundingFailed: __t.bool(), + payoutShortfall: __t.bool(), + unclaimed: __t.bool(), + updatedAt: __t.timestamp(), + deletedAt: __t.option(__t.timestamp()), +}); +export type FundingNotifyTrigger = __Infer; + export const GridBountyValue = __t.object("GridBountyValue", { get cells() { return __t.array(GridCell); @@ -384,6 +545,11 @@ export const IntegrationLinkRequest = __t.object("IntegrationLinkRequest", { externalId: __t.option(__t.string()), externalHandle: __t.option(__t.string()), createdAt: __t.timestamp(), + candidateAddress: __t.option(__t.string()), + pendingReason: __t.option(__t.string()), + unverified: __t.bool(), + matchedUsername: __t.option(__t.string()), + confirmed: __t.bool(), }); export type IntegrationLinkRequest = __Infer; @@ -447,6 +613,9 @@ export type LoyaltyRuleSpec = __Infer; export const MyAccount = __t.object("MyAccount", {}); export type MyAccount = __Infer; +export const MyBountyEscrowWallet = __t.object("MyBountyEscrowWallet", {}); +export type MyBountyEscrowWallet = __Infer; + export const MyBountyPayoutRecordAsPayer = __t.object("MyBountyPayoutRecordAsPayer", {}); export type MyBountyPayoutRecordAsPayer = __Infer; @@ -459,12 +628,21 @@ export type MyBountyPayoutRecordLogAsPayer = __Infer; +export const MyCraftBountyEscrow = __t.object("MyCraftBountyEscrow", {}); +export type MyCraftBountyEscrow = __Infer; + +export const MyCraftBountyFunding = __t.object("MyCraftBountyFunding", {}); +export type MyCraftBountyFunding = __Infer; + export const MyCraftBountyOverride = __t.object("MyCraftBountyOverride", {}); export type MyCraftBountyOverride = __Infer; export const MyCraftFilterNotifyTrigger = __t.object("MyCraftFilterNotifyTrigger", {}); export type MyCraftFilterNotifyTrigger = __Infer; +export const MyDiscordFundingNotifyTarget = __t.object("MyDiscordFundingNotifyTarget", {}); +export type MyDiscordFundingNotifyTarget = __Infer; + export const MyDiscordNotifySink = __t.object("MyDiscordNotifySink", {}); export type MyDiscordNotifySink = __Infer; @@ -480,6 +658,9 @@ export type MyEntitlementsAsContributor = __Infer; +export const MyFundingNotifyTrigger = __t.object("MyFundingNotifyTrigger", {}); +export type MyFundingNotifyTrigger = __Infer; + export const MyIntegrationLinkRequest = __t.object("MyIntegrationLinkRequest", {}); export type MyIntegrationLinkRequest = __Infer; @@ -504,6 +685,18 @@ export type MyPrivateCraftBountyAssignment = __Infer; +export const MySteloReceivingWallet = __t.object("MySteloReceivingWallet", {}); +export type MySteloReceivingWallet = __Infer; + +export const MySteloTransfer = __t.object("MySteloTransfer", {}); +export type MySteloTransfer = __Infer; + +export const MySteloWallet = __t.object("MySteloWallet", {}); +export type MySteloWallet = __Infer; + +export const MySteloWalletStatus = __t.object("MySteloWalletStatus", {}); +export type MySteloWalletStatus = __Infer; + export const Notification = __t.object("Notification", { id: __t.u64(), accountIdentity: __t.identity(), @@ -523,6 +716,15 @@ export const NotificationPayload = __t.enum("NotificationPayload", { }); export type NotificationPayload = __Infer; +export const NotificationV2 = __t.object("NotificationV2", { + id: __t.u64(), + accountIdentity: __t.identity(), + payloadJson: __t.string(), + createdAt: __t.timestamp(), + readAt: __t.option(__t.timestamp()), +}); +export type NotificationV2 = __Infer; + // The tagged union or sum type for the algebraic type `NotifySinkRef`. export const NotifySinkRef = __t.enum("NotifySinkRef", { get Toast() { @@ -542,6 +744,7 @@ export const PayerEntitlementRow = __t.object("PayerEntitlementRow", { currency: __t.string(), effortTotal: __t.i64(), earnedTotal: __t.i64(), + guaranteedTotal: __t.i64(), paidTotal: __t.i64(), }); export type PayerEntitlementRow = __Infer; @@ -585,6 +788,136 @@ export const SharedFilterRow = __t.object("SharedFilterRow", { }); export type SharedFilterRow = __Infer; +export const SteloCurrencyPolicy = __t.object("SteloCurrencyPolicy", { + currency: __t.string(), + action: __t.string(), + rollTo: __t.option(__t.string()), + afterDays: __t.u32(), +}); +export type SteloCurrencyPolicy = __Infer; + +export const SteloDirect = __t.object("SteloDirect", {}); +export type SteloDirect = __Infer; + +export const SteloExtras = __t.object("SteloExtras", { + payeePlayerId: __t.u64(), + fundingAccountId: __t.u64(), +}); +export type SteloExtras = __Infer; + +export const SteloHold = __t.object("SteloHold", { + craftId: __t.u64(), + fundingLinkId: __t.u64(), + ratioNumerator: __t.i64(), + ratioDenominator: __t.i64(), + effortCovered: __t.i64(), +}); +export type SteloHold = __Infer; + +export const SteloPayout = __t.object("SteloPayout", { + payeePlayerId: __t.u64(), + fundingAccountId: __t.u64(), + extras: __t.i64(), +}); +export type SteloPayout = __Infer; + +export const SteloPayoutSchedule = __t.object("SteloPayoutSchedule", { + id: __t.u8(), + intervalMicros: __t.i64(), + lastSweepAt: __t.timestamp(), +}); +export type SteloPayoutSchedule = __Infer; + +export const SteloReceivingWallet = __t.object("SteloReceivingWallet", { + id: __t.u64(), + accountIdentity: __t.identity(), + currency: __t.string(), + linkId: __t.u64(), +}); +export type SteloReceivingWallet = __Infer; + +export const SteloRefund = __t.object("SteloRefund", { + fundingAccountId: __t.u64(), +}); +export type SteloRefund = __Infer; + +export const SteloTopUp = __t.object("SteloTopUp", { + craftId: __t.u64(), + ratioNumerator: __t.i64(), + ratioDenominator: __t.i64(), + reserved: __t.i64(), +}); +export type SteloTopUp = __Infer; + +export const SteloTransfer = __t.object("SteloTransfer", { + id: __t.u64(), + ref: __t.string(), + currency: __t.string(), + payerAccountIdentity: __t.identity(), + fromAccountId: __t.u64(), + toAccountId: __t.u64(), + amount: __t.i64(), + steloAmount: __t.u64(), + get purpose() { + return SteloTransferPurpose; + }, + get state() { + return SteloTransferState; + }, + steloTransferId: __t.option(__t.u64()), + failureReason: __t.option(__t.string()), + createdAt: __t.timestamp(), + settledAt: __t.option(__t.timestamp()), +}); +export type SteloTransfer = __Infer; + +// The tagged union or sum type for the algebraic type `SteloTransferPurpose`. +export const SteloTransferPurpose = __t.enum("SteloTransferPurpose", { + get Hold() { + return SteloHold; + }, + get TopUp() { + return SteloTopUp; + }, + get Payout() { + return SteloPayout; + }, + get Refund() { + return SteloRefund; + }, + get Extras() { + return SteloExtras; + }, + get Direct() { + return SteloDirect; + }, +}); +export type SteloTransferPurpose = __Infer; + +// The tagged union or sum type for the algebraic type `SteloTransferState`. +export const SteloTransferState = __t.enum("SteloTransferState", { + Pending: __t.unit(), + Settled: __t.unit(), + Failed: __t.unit(), +}); +export type SteloTransferState = __Infer; + +export const SteloWallet = __t.object("SteloWallet", { + linkId: __t.u64(), + accountIdentity: __t.identity(), + currency: __t.string(), +}); +export type SteloWallet = __Infer; + +export const SteloWalletStatus = __t.object("SteloWalletStatus", { + linkId: __t.u64(), + accountIdentity: __t.identity(), + canPull: __t.bool(), + verified: __t.bool(), + updatedAt: __t.timestamp(), +}); +export type SteloWalletStatus = __Infer; + export const ToastNotifySinkRef = __t.object("ToastNotifySinkRef", {}); export type ToastNotifySinkRef = __Infer; diff --git a/common/bindings/brico-app/types/reducers.ts b/common/bindings/brico-app/types/reducers.ts index 3950f6c4..40c5af79 100644 --- a/common/bindings/brico-app/types/reducers.ts +++ b/common/bindings/brico-app/types/reducers.ts @@ -7,13 +7,18 @@ import {type Infer as __Infer} from "spacetimedb"; // Import all reducer arg schemas import AssignCraftBountyReducer from "../assign_craft_bounty_reducer"; +import AttachDiscordFundingTargetReducer from "../attach_discord_funding_target_reducer"; import AttachDiscordNotifySinkReducer from "../attach_discord_notify_sink_reducer"; import AttachDiscordNotifyTargetReducer from "../attach_discord_notify_target_reducer"; import AttachDiscordWatchDisplayReducer from "../attach_discord_watch_display_reducer"; import BeginIntegrationLinkForExternalReducer from "../begin_integration_link_for_external_reducer"; import BeginIntegrationLinkReducer from "../begin_integration_link_reducer"; +import CancelIntegrationLinkRequestReducer from "../cancel_integration_link_request_reducer"; import ClaimIntegrationLinkReducer from "../claim_integration_link_reducer"; +import ClearCraftBountyFundingReducer from "../clear_craft_bounty_funding_reducer"; import ClearCraftBountyReducer from "../clear_craft_bounty_reducer"; +import CloseCraftEscrowReducer from "../close_craft_escrow_reducer"; +import ConfirmIntegrationLinkCandidateReducer from "../confirm_integration_link_candidate_reducer"; import DeleteBountyRuleReducer from "../delete_bounty_rule_reducer"; import DeleteCraftBountyOverrideReducer from "../delete_craft_bounty_override_reducer"; import DeleteLoyaltyBonusTotalReducer from "../delete_loyalty_bonus_total_reducer"; @@ -22,43 +27,70 @@ import DeleteLoyaltyRuleReducer from "../delete_loyalty_rule_reducer"; import DeleteNotificationReducer from "../delete_notification_reducer"; import DeleteSavedCraftFilterReducer from "../delete_saved_craft_filter_reducer"; import DetachCraftFilterNotifyTriggerReducer from "../detach_craft_filter_notify_trigger_reducer"; +import DetachDiscordFundingTargetReducer from "../detach_discord_funding_target_reducer"; import DetachDiscordNotifySinkReducer from "../detach_discord_notify_sink_reducer"; import DetachDiscordNotifyTargetReducer from "../detach_discord_notify_target_reducer"; import DetachDiscordWatchDisplayReducer from "../detach_discord_watch_display_reducer"; +import DetachFundingNotifyTriggerReducer from "../detach_funding_notify_trigger_reducer"; import EnsureAccountReducer from "../ensure_account_reducer"; +import FailSteloTransferReducer from "../fail_stelo_transfer_reducer"; import ImportHistoricalBountyLedgerReducer from "../import_historical_bounty_ledger_reducer"; import LinkDiscordViaSpacetimeAuthReducer from "../link_discord_via_spacetime_auth_reducer"; import LinkIntegrationReducer from "../link_integration_reducer"; import MarkAllNotificationsReadReducer from "../mark_all_notifications_read_reducer"; import MarkNotificationReadReducer from "../mark_notification_read_reducer"; +import MigrateNotificationsToV2Reducer from "../migrate_notifications_to_v_2_reducer"; import NoteIntegrationLinkExternalReducer from "../note_integration_link_external_reducer"; +import NoteIntegrationLinkStatusReducer from "../note_integration_link_status_reducer"; import PostCraftNotificationReducer from "../post_craft_notification_reducer"; +import PostFundingNotificationReducer from "../post_funding_notification_reducer"; +import RaiseCraftEscrowFromRefundDueReducer from "../raise_craft_escrow_from_refund_due_reducer"; +import RecordBountyPaymentForReducer from "../record_bounty_payment_for_reducer"; import RecordBountyPaymentReducer from "../record_bounty_payment_reducer"; +import RecordSteloTransferIntentReducer from "../record_stelo_transfer_intent_reducer"; import RegisterServicePrincipalReducer from "../register_service_principal_reducer"; +import RegisterSteloWalletReducer from "../register_stelo_wallet_reducer"; +import ReleaseEscrowOverlapReducer from "../release_escrow_overlap_reducer"; import ReorderBountyRulesReducer from "../reorder_bounty_rules_reducer"; import RevokeServicePrincipalReducer from "../revoke_service_principal_reducer"; +import SetCraftBountyFundingReducer from "../set_craft_bounty_funding_reducer"; import SetDiscordWatchDisplayMessageReducer from "../set_discord_watch_display_message_reducer"; import SetDisplayNameReducer from "../set_display_name_reducer"; +import SetSteloCurrencyPolicyReducer from "../set_stelo_currency_policy_reducer"; +import SetSteloPayoutScheduleReducer from "../set_stelo_payout_schedule_reducer"; +import SetSteloReceivingWalletReducer from "../set_stelo_receiving_wallet_reducer"; +import SetSteloWalletStatusReducer from "../set_stelo_wallet_status_reducer"; +import SettleSteloTransferReducer from "../settle_stelo_transfer_reducer"; +import SettleUnclaimedPayeeReducer from "../settle_unclaimed_payee_reducer"; +import SubmitIntegrationLinkCandidateReducer from "../submit_integration_link_candidate_reducer"; +import SyncEscrowPoolUnclaimedReducer from "../sync_escrow_pool_unclaimed_reducer"; import UnlinkIntegrationReducer from "../unlink_integration_reducer"; import UpsertBountyRuleReducer from "../upsert_bounty_rule_reducer"; import UpsertCraftBountyEntitlementReducer from "../upsert_craft_bounty_entitlement_reducer"; import UpsertCraftBountyOverrideReducer from "../upsert_craft_bounty_override_reducer"; import UpsertCraftFilterNotifyTriggerReducer from "../upsert_craft_filter_notify_trigger_reducer"; +import UpsertDiscordFundingTargetTemplateReducer from "../upsert_discord_funding_target_template_reducer"; import UpsertDiscordGuildInstallReducer from "../upsert_discord_guild_install_reducer"; import UpsertDiscordNotifyTargetTemplateReducer from "../upsert_discord_notify_target_template_reducer"; +import UpsertFundingNotifyTriggerReducer from "../upsert_funding_notify_trigger_reducer"; import UpsertLoyaltyBonusTotalReducer from "../upsert_loyalty_bonus_total_reducer"; import UpsertLoyaltyRewardReducer from "../upsert_loyalty_reward_reducer"; import UpsertLoyaltyRuleReducer from "../upsert_loyalty_rule_reducer"; import UpsertSavedCraftFilterReducer from "../upsert_saved_craft_filter_reducer"; export type AssignCraftBountyParams = __Infer; +export type AttachDiscordFundingTargetParams = __Infer; export type AttachDiscordNotifySinkParams = __Infer; export type AttachDiscordNotifyTargetParams = __Infer; export type AttachDiscordWatchDisplayParams = __Infer; export type BeginIntegrationLinkParams = __Infer; export type BeginIntegrationLinkForExternalParams = __Infer; +export type CancelIntegrationLinkRequestParams = __Infer; export type ClaimIntegrationLinkParams = __Infer; export type ClearCraftBountyParams = __Infer; +export type ClearCraftBountyFundingParams = __Infer; +export type CloseCraftEscrowParams = __Infer; +export type ConfirmIntegrationLinkCandidateParams = __Infer; export type DeleteBountyRuleParams = __Infer; export type DeleteCraftBountyOverrideParams = __Infer; export type DeleteLoyaltyBonusTotalParams = __Infer; @@ -67,30 +99,52 @@ export type DeleteLoyaltyRuleParams = __Infer; export type DeleteNotificationParams = __Infer; export type DeleteSavedCraftFilterParams = __Infer; export type DetachCraftFilterNotifyTriggerParams = __Infer; +export type DetachDiscordFundingTargetParams = __Infer; export type DetachDiscordNotifySinkParams = __Infer; export type DetachDiscordNotifyTargetParams = __Infer; export type DetachDiscordWatchDisplayParams = __Infer; +export type DetachFundingNotifyTriggerParams = __Infer; export type EnsureAccountParams = __Infer; +export type FailSteloTransferParams = __Infer; export type ImportHistoricalBountyLedgerParams = __Infer; export type LinkDiscordViaSpacetimeAuthParams = __Infer; export type LinkIntegrationParams = __Infer; export type MarkAllNotificationsReadParams = __Infer; export type MarkNotificationReadParams = __Infer; +export type MigrateNotificationsToV2Params = __Infer; export type NoteIntegrationLinkExternalParams = __Infer; +export type NoteIntegrationLinkStatusParams = __Infer; export type PostCraftNotificationParams = __Infer; +export type PostFundingNotificationParams = __Infer; +export type RaiseCraftEscrowFromRefundDueParams = __Infer; export type RecordBountyPaymentParams = __Infer; +export type RecordBountyPaymentForParams = __Infer; +export type RecordSteloTransferIntentParams = __Infer; export type RegisterServicePrincipalParams = __Infer; +export type RegisterSteloWalletParams = __Infer; +export type ReleaseEscrowOverlapParams = __Infer; export type ReorderBountyRulesParams = __Infer; export type RevokeServicePrincipalParams = __Infer; +export type SetCraftBountyFundingParams = __Infer; export type SetDiscordWatchDisplayMessageParams = __Infer; export type SetDisplayNameParams = __Infer; +export type SetSteloCurrencyPolicyParams = __Infer; +export type SetSteloPayoutScheduleParams = __Infer; +export type SetSteloReceivingWalletParams = __Infer; +export type SetSteloWalletStatusParams = __Infer; +export type SettleSteloTransferParams = __Infer; +export type SettleUnclaimedPayeeParams = __Infer; +export type SubmitIntegrationLinkCandidateParams = __Infer; +export type SyncEscrowPoolUnclaimedParams = __Infer; export type UnlinkIntegrationParams = __Infer; export type UpsertBountyRuleParams = __Infer; export type UpsertCraftBountyEntitlementParams = __Infer; export type UpsertCraftBountyOverrideParams = __Infer; export type UpsertCraftFilterNotifyTriggerParams = __Infer; +export type UpsertDiscordFundingTargetTemplateParams = __Infer; export type UpsertDiscordGuildInstallParams = __Infer; export type UpsertDiscordNotifyTargetTemplateParams = __Infer; +export type UpsertFundingNotifyTriggerParams = __Infer; export type UpsertLoyaltyBonusTotalParams = __Infer; export type UpsertLoyaltyRewardParams = __Infer; export type UpsertLoyaltyRuleParams = __Infer; diff --git a/common/bindings/brico-app/unlink_integration_reducer.ts b/common/bindings/brico-app/unlink_integration_reducer.ts index 6b5d8547..378ad8df 100644 --- a/common/bindings/brico-app/unlink_integration_reducer.ts +++ b/common/bindings/brico-app/unlink_integration_reducer.ts @@ -3,7 +3,12 @@ /* eslint-disable */ /* tslint:disable */ -import {t as __t,} from "spacetimedb"; +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; export default { id: __t.u64(), diff --git a/common/bindings/brico-app/upsert_bounty_rule_reducer.ts b/common/bindings/brico-app/upsert_bounty_rule_reducer.ts index b3cc4eac..f3113b4a 100644 --- a/common/bindings/brico-app/upsert_bounty_rule_reducer.ts +++ b/common/bindings/brico-app/upsert_bounty_rule_reducer.ts @@ -3,9 +3,16 @@ /* eslint-disable */ /* tslint:disable */ -import {t as __t,} from "spacetimedb"; +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; -import {BountyRuleValue,} from "./types"; +import { + BountyRuleValue, +} from "./types"; export default { id: __t.string(), @@ -16,4 +23,5 @@ export default { priority: __t.i32(), private: __t.bool(), updatedAt: __t.timestamp(), + fundingLinkId: __t.option(__t.u64()), }; diff --git a/common/bindings/brico-app/upsert_craft_bounty_entitlement_reducer.ts b/common/bindings/brico-app/upsert_craft_bounty_entitlement_reducer.ts index 16efc81c..c9a8ebd4 100644 --- a/common/bindings/brico-app/upsert_craft_bounty_entitlement_reducer.ts +++ b/common/bindings/brico-app/upsert_craft_bounty_entitlement_reducer.ts @@ -3,7 +3,12 @@ /* eslint-disable */ /* tslint:disable */ -import {t as __t,} from "spacetimedb"; +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; export default { craftId: __t.u64(), @@ -12,5 +17,7 @@ export default { effort: __t.i64(), ratioNumerator: __t.i64(), ratioDenominator: __t.i64(), + baseRatioNumerator: __t.i64(), + baseRatioDenominator: __t.i64(), updatedAt: __t.timestamp(), }; diff --git a/common/bindings/brico-app/upsert_craft_bounty_override_reducer.ts b/common/bindings/brico-app/upsert_craft_bounty_override_reducer.ts index db70440a..fb87eb91 100644 --- a/common/bindings/brico-app/upsert_craft_bounty_override_reducer.ts +++ b/common/bindings/brico-app/upsert_craft_bounty_override_reducer.ts @@ -3,7 +3,12 @@ /* eslint-disable */ /* tslint:disable */ -import {t as __t,} from "spacetimedb"; +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; export default { craftId: __t.u64(), @@ -12,4 +17,5 @@ export default { currency: __t.string(), private: __t.bool(), updatedAt: __t.timestamp(), + fundingLinkId: __t.option(__t.u64()), }; diff --git a/common/bindings/brico-app/upsert_craft_filter_notify_trigger_reducer.ts b/common/bindings/brico-app/upsert_craft_filter_notify_trigger_reducer.ts index 63fa4ff6..17de2069 100644 --- a/common/bindings/brico-app/upsert_craft_filter_notify_trigger_reducer.ts +++ b/common/bindings/brico-app/upsert_craft_filter_notify_trigger_reducer.ts @@ -3,9 +3,16 @@ /* eslint-disable */ /* tslint:disable */ -import {t as __t,} from "spacetimedb"; +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; -import {NotifySinkRef,} from "./types"; +import { + NotifySinkRef, +} from "./types"; export default { id: __t.string(), diff --git a/common/bindings/brico-app/upsert_discord_funding_target_template_reducer.ts b/common/bindings/brico-app/upsert_discord_funding_target_template_reducer.ts new file mode 100644 index 00000000..24929256 --- /dev/null +++ b/common/bindings/brico-app/upsert_discord_funding_target_template_reducer.ts @@ -0,0 +1,20 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default { + id: __t.string(), + discordSinkId: __t.string(), + fundingFailedTemplate: __t.string(), + payoutShortfallTemplate: __t.string(), + unclaimedTemplate: __t.string(), + updatedAt: __t.timestamp(), +}; diff --git a/common/bindings/brico-app/upsert_discord_guild_install_reducer.ts b/common/bindings/brico-app/upsert_discord_guild_install_reducer.ts index 4481ad93..06886114 100644 --- a/common/bindings/brico-app/upsert_discord_guild_install_reducer.ts +++ b/common/bindings/brico-app/upsert_discord_guild_install_reducer.ts @@ -3,7 +3,12 @@ /* eslint-disable */ /* tslint:disable */ -import {t as __t,} from "spacetimedb"; +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; export default { guildId: __t.string(), diff --git a/common/bindings/brico-app/upsert_discord_notify_target_template_reducer.ts b/common/bindings/brico-app/upsert_discord_notify_target_template_reducer.ts index 57a372a6..92f936de 100644 --- a/common/bindings/brico-app/upsert_discord_notify_target_template_reducer.ts +++ b/common/bindings/brico-app/upsert_discord_notify_target_template_reducer.ts @@ -3,7 +3,12 @@ /* eslint-disable */ /* tslint:disable */ -import {t as __t,} from "spacetimedb"; +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; export default { id: __t.string(), diff --git a/common/bindings/brico-app/upsert_funding_notify_trigger_reducer.ts b/common/bindings/brico-app/upsert_funding_notify_trigger_reducer.ts new file mode 100644 index 00000000..94b41906 --- /dev/null +++ b/common/bindings/brico-app/upsert_funding_notify_trigger_reducer.ts @@ -0,0 +1,26 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +import { + NotifySinkRef, +} from "./types"; + +export default { + id: __t.string(), + get sink() { + return NotifySinkRef; + }, + fundingFailed: __t.bool(), + payoutShortfall: __t.bool(), + unclaimed: __t.bool(), + updatedAt: __t.timestamp(), +}; diff --git a/common/bindings/brico-app/upsert_loyalty_bonus_total_reducer.ts b/common/bindings/brico-app/upsert_loyalty_bonus_total_reducer.ts index b59fb648..022b7ad7 100644 --- a/common/bindings/brico-app/upsert_loyalty_bonus_total_reducer.ts +++ b/common/bindings/brico-app/upsert_loyalty_bonus_total_reducer.ts @@ -3,7 +3,12 @@ /* eslint-disable */ /* tslint:disable */ -import {t as __t,} from "spacetimedb"; +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; export default { payerAccountIdentity: __t.identity(), diff --git a/common/bindings/brico-app/upsert_loyalty_reward_reducer.ts b/common/bindings/brico-app/upsert_loyalty_reward_reducer.ts index 8b3d4494..68767d43 100644 --- a/common/bindings/brico-app/upsert_loyalty_reward_reducer.ts +++ b/common/bindings/brico-app/upsert_loyalty_reward_reducer.ts @@ -3,7 +3,12 @@ /* eslint-disable */ /* tslint:disable */ -import {t as __t,} from "spacetimedb"; +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; export default { payeePlayerId: __t.u64(), diff --git a/common/bindings/brico-app/upsert_loyalty_rule_reducer.ts b/common/bindings/brico-app/upsert_loyalty_rule_reducer.ts index 69bdf5e0..7e3eb1ca 100644 --- a/common/bindings/brico-app/upsert_loyalty_rule_reducer.ts +++ b/common/bindings/brico-app/upsert_loyalty_rule_reducer.ts @@ -3,9 +3,16 @@ /* eslint-disable */ /* tslint:disable */ -import {t as __t,} from "spacetimedb"; +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; -import {LoyaltyRuleSpec,} from "./types"; +import { + LoyaltyRuleSpec, +} from "./types"; export default { id: __t.option(__t.u64()), diff --git a/common/bindings/brico-app/upsert_saved_craft_filter_reducer.ts b/common/bindings/brico-app/upsert_saved_craft_filter_reducer.ts index ef2e5684..1c41f3b3 100644 --- a/common/bindings/brico-app/upsert_saved_craft_filter_reducer.ts +++ b/common/bindings/brico-app/upsert_saved_craft_filter_reducer.ts @@ -3,7 +3,12 @@ /* eslint-disable */ /* tslint:disable */ -import {t as __t,} from "spacetimedb"; +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; export default { id: __t.string(), diff --git a/common/bindings/package.json b/common/bindings/package.json index f7640800..7512926b 100644 --- a/common/bindings/package.json +++ b/common/bindings/package.json @@ -8,9 +8,14 @@ "./prism": "./prism/index.ts", "./prism/*": "./prism/*.ts", "./brico-app": "./brico-app/index.ts", - "./brico-app/*": "./brico-app/*.ts" + "./brico-app/*": "./brico-app/*.ts", + "./stelo": "./stelo/index.ts", + "./stelo/*": "./stelo/*.ts" }, "dependencies": { "spacetimedb": "2.10.2" + }, + "scripts": { + "generate:stelo": "node scripts/generate-stelo.mjs" } } diff --git a/common/bindings/scripts/generate-stelo.mjs b/common/bindings/scripts/generate-stelo.mjs new file mode 100644 index 00000000..a8900c8c --- /dev/null +++ b/common/bindings/scripts/generate-stelo.mjs @@ -0,0 +1,33 @@ +// Regenerates ./stelo from the live stelofinance-prod schema (third-party module, no local source). +import {spawnSync} from 'node:child_process'; +import {mkdirSync, mkdtempSync, rmSync, writeFileSync} from 'node:fs'; +import {tmpdir} from 'node:os'; +import {dirname, join} from 'node:path'; +import {fileURLToPath} from 'node:url'; + +const HOST = process.env.STELO_HOST ?? 'maincloud.spacetimedb.com'; +const DATABASE = process.env.STELO_DATABASE ?? 'stelofinance-prod'; +const outDir = join(dirname(fileURLToPath(import.meta.url)), '..', 'stelo'); + +const res = await fetch(`https://${HOST}/v1/database/${DATABASE}/schema?version=10`); +if (!res.ok) throw new Error(`schema fetch failed: ${res.status} ${res.statusText}`); +const schema = await res.json(); + +const scratch = mkdtempSync(join(tmpdir(), 'stelo-bindings-')); +try { + const moduleDef = join(scratch, 'schema.v10.json'); + writeFileSync(moduleDef, JSON.stringify({ V10: schema })); + // `generate --module-def` only runs once a buildable-module check passes; an empty spacetimedb/ dir satisfies it. + const cwd = join(scratch, 'cwd'); + mkdirSync(join(cwd, 'spacetimedb'), { recursive: true }); + + rmSync(outDir, { recursive: true, force: true }); + const r = spawnSync( + process.platform === 'win32' ? 'spacetime.exe' : 'spacetime', + ['generate', '-y', '--module-def', moduleDef, '--lang', 'typescript', '--out-dir', outDir], + { cwd, stdio: 'inherit' }, + ); + if (r.status !== 0) throw new Error(`spacetime generate exited with ${r.status ?? r.error}`); +} finally { + rmSync(scratch, { recursive: true, force: true }); +} diff --git a/common/bindings/stelo/account_lookup_procedure.ts b/common/bindings/stelo/account_lookup_procedure.ts new file mode 100644 index 00000000..ba3b9d61 --- /dev/null +++ b/common/bindings/stelo/account_lookup_procedure.ts @@ -0,0 +1,13 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import {t as __t,} from "spacetimedb"; + +import {AccountSearchHit,} from "./types"; + +export const params = { + accountId: __t.u64(), +}; +export const returnType = __t.result(AccountSearchHit, __t.string()) \ No newline at end of file diff --git a/common/bindings/stelo/account_search_procedure.ts b/common/bindings/stelo/account_search_procedure.ts new file mode 100644 index 00000000..9db52680 --- /dev/null +++ b/common/bindings/stelo/account_search_procedure.ts @@ -0,0 +1,17 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import {t as __t,} from "spacetimedb"; + +import {AccountSearchHit, AccountSearchScope,} from "./types"; + +export const params = { + term: __t.string(), + ledgerId: __t.u64(), + get scope() { + return __t.option(AccountSearchScope); + }, +}; +export const returnType = __t.result(__t.array(AccountSearchHit), __t.string()) \ No newline at end of file diff --git a/common/bindings/stelo/app_search_procedure.ts b/common/bindings/stelo/app_search_procedure.ts new file mode 100644 index 00000000..527cbe7e --- /dev/null +++ b/common/bindings/stelo/app_search_procedure.ts @@ -0,0 +1,13 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import {t as __t,} from "spacetimedb"; + +import {AppSearchHit,} from "./types"; + +export const params = { + term: __t.string(), +}; +export const returnType = __t.result(__t.array(AppSearchHit), __t.string()) \ No newline at end of file diff --git a/common/bindings/stelo/create_account_reducer.ts b/common/bindings/stelo/create_account_reducer.ts new file mode 100644 index 00000000..72659cf4 --- /dev/null +++ b/common/bindings/stelo/create_account_reducer.ts @@ -0,0 +1,19 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import {t as __t,} from "spacetimedb"; + +import {AccountKind,} from "./types"; + +export default { + ledgerId: __t.u64(), + get kind() { + return AccountKind; + }, + address: __t.option(__t.string()), + webhook: __t.option(__t.string()), + label: __t.option(__t.string()), + isPrimary: __t.bool(), +}; diff --git a/common/bindings/stelo/create_account_token_procedure.ts b/common/bindings/stelo/create_account_token_procedure.ts new file mode 100644 index 00000000..64163c60 --- /dev/null +++ b/common/bindings/stelo/create_account_token_procedure.ts @@ -0,0 +1,13 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import {t as __t,} from "spacetimedb"; + +export const params = { + accountId: __t.u64(), + label: __t.string(), + entropy: __t.string(), +}; +export const returnType = __t.result(__t.string(), __t.string()) \ No newline at end of file diff --git a/common/bindings/stelo/create_app_ticket_reducer.ts b/common/bindings/stelo/create_app_ticket_reducer.ts new file mode 100644 index 00000000..ebd61824 --- /dev/null +++ b/common/bindings/stelo/create_app_ticket_reducer.ts @@ -0,0 +1,11 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import {t as __t,} from "spacetimedb"; + +export default { + name: __t.string(), + sub: __t.string(), +}; diff --git a/common/bindings/stelo/create_ledger_reducer.ts b/common/bindings/stelo/create_ledger_reducer.ts new file mode 100644 index 00000000..cde23164 --- /dev/null +++ b/common/bindings/stelo/create_ledger_reducer.ts @@ -0,0 +1,16 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import {t as __t,} from "spacetimedb"; + +import {LedgerKind,} from "./types"; + +export default { + name: __t.string(), + scale: __t.u8(), + get kind() { + return LedgerKind; + }, +}; diff --git a/common/bindings/stelo/create_transfer_reducer.ts b/common/bindings/stelo/create_transfer_reducer.ts new file mode 100644 index 00000000..79a6fc67 --- /dev/null +++ b/common/bindings/stelo/create_transfer_reducer.ts @@ -0,0 +1,15 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import {t as __t,} from "spacetimedb"; + +export default { + sendingAccountId: __t.u64(), + receivingAccountId: __t.u64(), + amount: __t.u64(), + memo: __t.option(__t.string()), + idempotencyKey: __t.string(), + pending: __t.bool(), +}; diff --git a/common/bindings/stelo/finalize_transfer_reducer.ts b/common/bindings/stelo/finalize_transfer_reducer.ts new file mode 100644 index 00000000..6b07749e --- /dev/null +++ b/common/bindings/stelo/finalize_transfer_reducer.ts @@ -0,0 +1,11 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import {t as __t,} from "spacetimedb"; + +export default { + transferId: __t.u64(), + amount: __t.u64(), +}; diff --git a/common/bindings/stelo/grant_account_member_reducer.ts b/common/bindings/stelo/grant_account_member_reducer.ts new file mode 100644 index 00000000..4fd45836 --- /dev/null +++ b/common/bindings/stelo/grant_account_member_reducer.ts @@ -0,0 +1,16 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import {t as __t,} from "spacetimedb"; + +import {Role,} from "./types"; + +export default { + accountId: __t.u64(), + memberId: __t.identity(), + get role() { + return Role; + }, +}; diff --git a/common/bindings/stelo/index.ts b/common/bindings/stelo/index.ts new file mode 100644 index 00000000..16c634e0 --- /dev/null +++ b/common/bindings/stelo/index.ts @@ -0,0 +1,335 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +// This was generated using spacetimedb cli version 2.10.0 (commit baca5cdf77577ed4e3f30da48a5158189c4ea43f). + +/* eslint-disable */ +/* tslint:disable */ +import { + convertToAccessorMap as __convertToAccessorMap, + DbConnectionBuilder as __DbConnectionBuilder, + type DbConnectionConfig as __DbConnectionConfig, + DbConnectionImpl as __DbConnectionImpl, + type ErrorContextInterface as __ErrorContextInterface, + type EventContextInterface as __EventContextInterface, + makeQueryBuilder as __makeQueryBuilder, + procedures as __procedures, + procedureSchema as __procedureSchema, + type QueryBuilder as __QueryBuilder, + type ReducerEventContextInterface as __ReducerEventContextInterface, + reducers as __reducers, + reducerSchema as __reducerSchema, + type RemoteModule as __RemoteModule, + schema as __schema, + SubscriptionBuilderImpl as __SubscriptionBuilderImpl, + type SubscriptionEventContextInterface as __SubscriptionEventContextInterface, + type SubscriptionHandleImpl as __SubscriptionHandleImpl, + table as __table, +} from "spacetimedb"; + +// Import all procedure arg schemas +import * as AccountLookupProcedure from "./account_lookup_procedure"; +import * as AccountSearchProcedure from "./account_search_procedure"; +import * as AppSearchProcedure from "./app_search_procedure"; + +// Import all reducer arg schemas +import CreateAccountReducer from "./create_account_reducer"; +import * as CreateAccountTokenProcedure from "./create_account_token_procedure"; +import CreateAppTicketReducer from "./create_app_ticket_reducer"; +import CreateLedgerReducer from "./create_ledger_reducer"; +import CreateTransferReducer from "./create_transfer_reducer"; +import FinalizeTransferReducer from "./finalize_transfer_reducer"; +import GrantAccountMemberReducer from "./grant_account_member_reducer"; +import LedgerAuditRow from "./ledger_audit_table"; + +// Import all table schema definitions +import LedgerRow from "./ledger_table"; +import MyAccountsMembersRow from "./my_accounts_members_table"; +import MyAccountsRow from "./my_accounts_table"; +import MyAccountsTokensRow from "./my_accounts_tokens_table"; +import MyAppTicketsRow from "./my_app_tickets_table"; +import MyAppsRow from "./my_apps_table"; +import MyTransfersRow from "./my_transfers_table"; +import MyUserRow from "./my_user_table"; +import ReplaceAppTicketReducer from "./replace_app_ticket_reducer"; +import RevokeAccountMemberReducer from "./revoke_account_member_reducer"; +import RevokeAccountTokensReducer from "./revoke_account_tokens_reducer"; +import SetAccountLabelReducer from "./set_account_label_reducer"; +import SetAccountPrimaryReducer from "./set_account_primary_reducer"; +import SetAccountWebhookReducer from "./set_account_webhook_reducer"; +import UpdateAccountAddressReducer from "./update_account_address_reducer"; +import UserRow from "./user_table"; + +/** Type-only namespace exports for generated type groups. */ + +/** The schema information for all tables in this module. This is defined the same was as the tables would have been defined in the server. */ +const tablesSchema = __schema({ + ledger: __table({ + name: 'ledger', + indexes: [ + { accessor: 'id', name: 'ledger_id_idx_btree', algorithm: 'btree', columns: [ + 'id', + ] }, + { accessor: 'name', name: 'ledger_name_idx_btree', algorithm: 'btree', columns: [ + 'name', + ] }, + ], + constraints: [ + { name: 'ledger_id_key', constraint: 'unique', columns: ['id'] }, + { name: 'ledger_name_key', constraint: 'unique', columns: ['name'] }, + ], + }, LedgerRow), + user: __table({ + name: 'user', + indexes: [ + { accessor: 'bitcraft_player_id', name: 'user_bitcraft_player_id_idx_btree', algorithm: 'btree', columns: [ + 'bitcraftPlayerId', + ] }, + { accessor: 'bitcraft_username', name: 'user_bitcraft_username_idx_btree', algorithm: 'btree', columns: [ + 'bitcraftUsername', + ] }, + { accessor: 'bitcraft_username_normalized', name: 'user_bitcraft_username_normalized_idx_btree', algorithm: 'btree', columns: [ + 'bitcraftUsernameNormalized', + ] }, + { accessor: 'id', name: 'user_id_idx_btree', algorithm: 'btree', columns: [ + 'id', + ] }, + ], + constraints: [ + { name: 'user_bitcraft_player_id_key', constraint: 'unique', columns: ['bitcraftPlayerId'] }, + { name: 'user_bitcraft_username_key', constraint: 'unique', columns: ['bitcraftUsername'] }, + { name: 'user_id_key', constraint: 'unique', columns: ['id'] }, + ], + }, UserRow), + ledgerAudit: __table({ + name: 'ledger_audit', + indexes: [ + ], + constraints: [ + ], + }, LedgerAuditRow), + myAccounts: __table({ + name: 'my_accounts', + indexes: [ + ], + constraints: [ + ], + }, MyAccountsRow), + myAccountsMembers: __table({ + name: 'my_accounts_members', + indexes: [ + ], + constraints: [ + ], + }, MyAccountsMembersRow), + myAccountsTokens: __table({ + name: 'my_accounts_tokens', + indexes: [ + ], + constraints: [ + ], + }, MyAccountsTokensRow), + myAppTickets: __table({ + name: 'my_app_tickets', + indexes: [ + ], + constraints: [ + ], + }, MyAppTicketsRow), + myApps: __table({ + name: 'my_apps', + indexes: [ + ], + constraints: [ + ], + }, MyAppsRow), + myTransfers: __table({ + name: 'my_transfers', + indexes: [ + ], + constraints: [ + ], + }, MyTransfersRow), + myUser: __table({ + name: 'my_user', + indexes: [ + ], + constraints: [ + ], + }, MyUserRow), +}); + +/** The schema information for all reducers in this module. This is defined the same way as the reducers would have been defined in the server, except the body of the reducer is omitted in code generation. */ +const reducersSchema = __reducers( + __reducerSchema("create_account", CreateAccountReducer), + __reducerSchema("create_app_ticket", CreateAppTicketReducer), + __reducerSchema("create_ledger", CreateLedgerReducer), + __reducerSchema("create_transfer", CreateTransferReducer), + __reducerSchema("finalize_transfer", FinalizeTransferReducer), + __reducerSchema("grant_account_member", GrantAccountMemberReducer), + __reducerSchema("replace_app_ticket", ReplaceAppTicketReducer), + __reducerSchema("revoke_account_member", RevokeAccountMemberReducer), + __reducerSchema("revoke_account_tokens", RevokeAccountTokensReducer), + __reducerSchema("set_account_label", SetAccountLabelReducer), + __reducerSchema("set_account_primary", SetAccountPrimaryReducer), + __reducerSchema("set_account_webhook", SetAccountWebhookReducer), + __reducerSchema("update_account_address", UpdateAccountAddressReducer), +); + +/** The schema information for all procedures in this module. This is defined the same way as the procedures would have been defined in the server. */ +const proceduresSchema = __procedures( + __procedureSchema("account_lookup", AccountLookupProcedure.params, AccountLookupProcedure.returnType), + __procedureSchema("account_search", AccountSearchProcedure.params, AccountSearchProcedure.returnType), + __procedureSchema("app_search", AppSearchProcedure.params, AppSearchProcedure.returnType), + __procedureSchema("create_account_token", CreateAccountTokenProcedure.params, CreateAccountTokenProcedure.returnType), +); + +type __SchemaWithTableAccessorAliases = Omit & { + tables: typeof tablesSchema.schemaType.tables & { + /** @deprecated Use `ledgerAudit` instead. This alias will be removed in the next major version. */ + readonly "ledger_audit": Omit & { readonly accessorName: "ledger_audit" }; + /** @deprecated Use `myAccounts` instead. This alias will be removed in the next major version. */ + readonly "my_accounts": Omit & { readonly accessorName: "my_accounts" }; + /** @deprecated Use `myAccountsMembers` instead. This alias will be removed in the next major version. */ + readonly "my_accounts_members": Omit & { readonly accessorName: "my_accounts_members" }; + /** @deprecated Use `myAccountsTokens` instead. This alias will be removed in the next major version. */ + readonly "my_accounts_tokens": Omit & { readonly accessorName: "my_accounts_tokens" }; + /** @deprecated Use `myAppTickets` instead. This alias will be removed in the next major version. */ + readonly "my_app_tickets": Omit & { readonly accessorName: "my_app_tickets" }; + /** @deprecated Use `myApps` instead. This alias will be removed in the next major version. */ + readonly "my_apps": Omit & { readonly accessorName: "my_apps" }; + /** @deprecated Use `myTransfers` instead. This alias will be removed in the next major version. */ + readonly "my_transfers": Omit & { readonly accessorName: "my_transfers" }; + /** @deprecated Use `myUser` instead. This alias will be removed in the next major version. */ + readonly "my_user": Omit & { readonly accessorName: "my_user" }; + }; +}; + +/** The remote SpacetimeDB module schema, both runtime and type information. */ +const REMOTE_MODULE = { + versionInfo: { + cliVersion: "2.10.0" as const, + }, + tables: tablesSchema.schemaType.tables as __SchemaWithTableAccessorAliases["tables"], + reducers: reducersSchema.reducersType.reducers, + ...proceduresSchema, +} satisfies __RemoteModule< + __SchemaWithTableAccessorAliases, + typeof reducersSchema.reducersType, + typeof proceduresSchema +>; + +const tableAccessorAliases = { + "ledger_audit": "ledgerAudit", + "my_accounts": "myAccounts", + "my_accounts_members": "myAccountsMembers", + "my_accounts_tokens": "myAccountsTokens", + "my_app_tickets": "myAppTickets", + "my_apps": "myApps", + "my_transfers": "myTransfers", + "my_user": "myUser", +} as const; + +function __withTableAccessorAliases(target: T, freeze = false): T { + const out = Object.create(Object.getPrototypeOf(target)) as T & Record; + Object.defineProperties(out, Object.getOwnPropertyDescriptors(target)); + for (const [deprecatedAccessor, targetAccessor] of Object.entries(tableAccessorAliases)) { + if (deprecatedAccessor in out) { + continue; + } + Object.defineProperty(out, deprecatedAccessor, { + enumerable: true, + configurable: false, + get: () => out[targetAccessor], + }); + } + return freeze ? Object.freeze(out) : out; +} + +type __DbViewBase = __DbConnectionImpl["db"]; +export type DbView = __DbViewBase & { + /** @deprecated Use `ledgerAudit` instead. This alias will be removed in the next major version. */ + readonly "ledger_audit": __DbViewBase["ledgerAudit"]; + /** @deprecated Use `myAccounts` instead. This alias will be removed in the next major version. */ + readonly "my_accounts": __DbViewBase["myAccounts"]; + /** @deprecated Use `myAccountsMembers` instead. This alias will be removed in the next major version. */ + readonly "my_accounts_members": __DbViewBase["myAccountsMembers"]; + /** @deprecated Use `myAccountsTokens` instead. This alias will be removed in the next major version. */ + readonly "my_accounts_tokens": __DbViewBase["myAccountsTokens"]; + /** @deprecated Use `myAppTickets` instead. This alias will be removed in the next major version. */ + readonly "my_app_tickets": __DbViewBase["myAppTickets"]; + /** @deprecated Use `myApps` instead. This alias will be removed in the next major version. */ + readonly "my_apps": __DbViewBase["myApps"]; + /** @deprecated Use `myTransfers` instead. This alias will be removed in the next major version. */ + readonly "my_transfers": __DbViewBase["myTransfers"]; + /** @deprecated Use `myUser` instead. This alias will be removed in the next major version. */ + readonly "my_user": __DbViewBase["myUser"]; +}; + +type __TablesBase = __QueryBuilder; +export type Tables = __TablesBase & { + /** @deprecated Use `ledgerAudit` instead. This alias will be removed in the next major version. */ + readonly "ledger_audit": __TablesBase["ledgerAudit"]; + /** @deprecated Use `myAccounts` instead. This alias will be removed in the next major version. */ + readonly "my_accounts": __TablesBase["myAccounts"]; + /** @deprecated Use `myAccountsMembers` instead. This alias will be removed in the next major version. */ + readonly "my_accounts_members": __TablesBase["myAccountsMembers"]; + /** @deprecated Use `myAccountsTokens` instead. This alias will be removed in the next major version. */ + readonly "my_accounts_tokens": __TablesBase["myAccountsTokens"]; + /** @deprecated Use `myAppTickets` instead. This alias will be removed in the next major version. */ + readonly "my_app_tickets": __TablesBase["myAppTickets"]; + /** @deprecated Use `myApps` instead. This alias will be removed in the next major version. */ + readonly "my_apps": __TablesBase["myApps"]; + /** @deprecated Use `myTransfers` instead. This alias will be removed in the next major version. */ + readonly "my_transfers": __TablesBase["myTransfers"]; + /** @deprecated Use `myUser` instead. This alias will be removed in the next major version. */ + readonly "my_user": __TablesBase["myUser"]; +}; + +/** The tables available in this remote SpacetimeDB module. Each table reference doubles as a query builder. */ +const tablesBase: __TablesBase = __makeQueryBuilder(tablesSchema.schemaType); +export const tables: Tables = __withTableAccessorAliases(tablesBase, true) as Tables; + +/** The reducers available in this remote SpacetimeDB module. */ +export const reducers = __convertToAccessorMap(reducersSchema.reducersType.reducers); + +/** The procedures available in this remote SpacetimeDB module. */ +export const procedures = __convertToAccessorMap(proceduresSchema.procedures); + +/** The context type returned in callbacks for all possible events. */ +export type EventContext = Omit<__EventContextInterface, "db"> & { db: DbView }; +/** The context type returned in callbacks for reducer events. */ +export type ReducerEventContext = Omit<__ReducerEventContextInterface, "db"> & { db: DbView }; +/** The context type returned in callbacks for subscription events. */ +export type SubscriptionEventContext = Omit<__SubscriptionEventContextInterface, "db"> & { db: DbView }; +/** The context type returned in callbacks for error events. */ +export type ErrorContext = Omit<__ErrorContextInterface, "db"> & { db: DbView }; +/** The subscription handle type to manage active subscriptions created from a {@link SubscriptionBuilder}. */ +export type SubscriptionHandle = __SubscriptionHandleImpl; + +/** Builder class to configure a new subscription to the remote SpacetimeDB instance. */ +export class SubscriptionBuilder extends __SubscriptionBuilderImpl {} + +/** Builder class to configure a new database connection to the remote SpacetimeDB instance. */ +export class DbConnectionBuilder extends __DbConnectionBuilder {} + +/** The typed database connection to manage connections to the remote SpacetimeDB instance. This class has type information specific to the generated module. */ +export class DbConnection extends __DbConnectionImpl { + declare db: DbView; + + constructor(config: __DbConnectionConfig) { + super(config); + this.db = __withTableAccessorAliases(this.db) as DbView; + } + + /** Creates a new {@link DbConnectionBuilder} to configure and connect to the remote SpacetimeDB instance. */ + static builder = (): DbConnectionBuilder => { + return new DbConnectionBuilder(REMOTE_MODULE, (config: __DbConnectionConfig) => new DbConnection(config)); + }; + + /** Creates a new {@link SubscriptionBuilder} to configure a subscription to the remote SpacetimeDB instance. */ + override subscriptionBuilder = (): SubscriptionBuilder => { + return new SubscriptionBuilder(this); + }; +} + diff --git a/common/bindings/stelo/ledger_audit_table.ts b/common/bindings/stelo/ledger_audit_table.ts new file mode 100644 index 00000000..d25c5f68 --- /dev/null +++ b/common/bindings/stelo/ledger_audit_table.ts @@ -0,0 +1,14 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import {t as __t,} from "spacetimedb"; + +export default __t.row({ + ledgerId: __t.u64().primaryKey().name("ledger_id"), + ledgerName: __t.string().name("ledger_name"), + debitsNet: __t.i64().name("debits_net"), + creditsNet: __t.i64().name("credits_net"), + balanced: __t.bool(), +}); diff --git a/common/bindings/stelo/ledger_table.ts b/common/bindings/stelo/ledger_table.ts new file mode 100644 index 00000000..2f0cfa4c --- /dev/null +++ b/common/bindings/stelo/ledger_table.ts @@ -0,0 +1,17 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import {t as __t,} from "spacetimedb"; +import {LedgerKind,} from "./types"; + + +export default __t.row({ + id: __t.u64().primaryKey(), + name: __t.string(), + scale: __t.u8(), + get kind() { + return LedgerKind; + }, +}); diff --git a/common/bindings/stelo/my_accounts_members_table.ts b/common/bindings/stelo/my_accounts_members_table.ts new file mode 100644 index 00000000..69c3f9f6 --- /dev/null +++ b/common/bindings/stelo/my_accounts_members_table.ts @@ -0,0 +1,23 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import {t as __t,} from "spacetimedb"; +import {MemberKind, Role,} from "./types"; + + +export default __t.row({ + id: __t.u64().primaryKey(), + accountId: __t.u64().name("account_id"), + memberId: __t.identity().name("member_id"), + get kind() { + return MemberKind; + }, + name: __t.string(), + get role() { + return Role; + }, + updatedAt: __t.timestamp().name("updated_at"), + createdAt: __t.timestamp().name("created_at"), +}); diff --git a/common/bindings/stelo/my_accounts_table.ts b/common/bindings/stelo/my_accounts_table.ts new file mode 100644 index 00000000..53111f77 --- /dev/null +++ b/common/bindings/stelo/my_accounts_table.ts @@ -0,0 +1,31 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import {t as __t,} from "spacetimedb"; +import {AccountKind, LedgerKind, Role,} from "./types"; + + +export default __t.row({ + accountId: __t.u64().primaryKey().name("account_id"), + address: __t.string(), + label: __t.option(__t.string()), + get kind() { + return AccountKind; + }, + balance: __t.u64(), + ledgerId: __t.u64().name("ledger_id"), + ledgerName: __t.string().name("ledger_name"), + ledgerScale: __t.u8().name("ledger_scale"), + get ledgerKind() { + return LedgerKind.name("ledger_kind"); + }, + get role() { + return Role; + }, + isPrimary: __t.bool().name("is_primary"), + ownerUsername: __t.option(__t.string()).name("owner_username"), + webhook: __t.option(__t.string()), + createdAt: __t.timestamp().name("created_at"), +}); diff --git a/common/bindings/stelo/my_accounts_tokens_table.ts b/common/bindings/stelo/my_accounts_tokens_table.ts new file mode 100644 index 00000000..d30aa8cd --- /dev/null +++ b/common/bindings/stelo/my_accounts_tokens_table.ts @@ -0,0 +1,14 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import {t as __t,} from "spacetimedb"; + +export default __t.row({ + id: __t.u64().primaryKey(), + accountId: __t.u64().name("account_id"), + label: __t.string(), + createdBy: __t.identity().name("created_by"), + createdAt: __t.timestamp().name("created_at"), +}); diff --git a/common/bindings/stelo/my_app_tickets_table.ts b/common/bindings/stelo/my_app_tickets_table.ts new file mode 100644 index 00000000..fe013494 --- /dev/null +++ b/common/bindings/stelo/my_app_tickets_table.ts @@ -0,0 +1,18 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import {t as __t,} from "spacetimedb"; +import {AppTicketPurpose,} from "./types"; + + +export default __t.row({ + id: __t.u64().primaryKey(), + name: __t.string(), + get purpose() { + return AppTicketPurpose; + }, + expiresAt: __t.timestamp().name("expires_at"), + createdAt: __t.timestamp().name("created_at"), +}); diff --git a/common/bindings/stelo/my_apps_table.ts b/common/bindings/stelo/my_apps_table.ts new file mode 100644 index 00000000..4bc051ad --- /dev/null +++ b/common/bindings/stelo/my_apps_table.ts @@ -0,0 +1,12 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import {t as __t,} from "spacetimedb"; + +export default __t.row({ + id: __t.identity().primaryKey(), + name: __t.string(), + createdAt: __t.timestamp().name("created_at"), +}); diff --git a/common/bindings/stelo/my_transfers_table.ts b/common/bindings/stelo/my_transfers_table.ts new file mode 100644 index 00000000..8cc5a5ab --- /dev/null +++ b/common/bindings/stelo/my_transfers_table.ts @@ -0,0 +1,32 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import {t as __t,} from "spacetimedb"; +import {TransferKind, TransferState,} from "./types"; + + +export default __t.row({ + id: __t.u64(), + debitAccountId: __t.u64().name("debit_account_id"), + creditAccountId: __t.u64().name("credit_account_id"), + debitAddress: __t.string().name("debit_address"), + creditAddress: __t.string().name("credit_address"), + debitUsername: __t.option(__t.string()).name("debit_username"), + creditUsername: __t.option(__t.string()).name("credit_username"), + pendingAmount: __t.option(__t.u64()).name("pending_amount"), + postedAmount: __t.option(__t.u64()).name("posted_amount"), + ledgerId: __t.u64().name("ledger_id"), + ledgerName: __t.string().name("ledger_name"), + ledgerScale: __t.u8().name("ledger_scale"), + get kind() { + return TransferKind; + }, + get state() { + return TransferState; + }, + memo: __t.option(__t.string()), + createdAt: __t.timestamp().name("created_at"), + finalizedAt: __t.option(__t.timestamp()).name("finalized_at"), +}); diff --git a/common/bindings/stelo/my_user_table.ts b/common/bindings/stelo/my_user_table.ts new file mode 100644 index 00000000..150008b4 --- /dev/null +++ b/common/bindings/stelo/my_user_table.ts @@ -0,0 +1,14 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import {t as __t,} from "spacetimedb"; + +export default __t.row({ + id: __t.identity(), + bitcraftUsername: __t.string().name("bitcraft_username"), + bitcraftPlayerId: __t.u64().name("bitcraft_player_id"), + createdAt: __t.timestamp().name("created_at"), + isAdmin: __t.bool().name("is_admin"), +}); diff --git a/common/bindings/stelo/replace_app_ticket_reducer.ts b/common/bindings/stelo/replace_app_ticket_reducer.ts new file mode 100644 index 00000000..ebd61824 --- /dev/null +++ b/common/bindings/stelo/replace_app_ticket_reducer.ts @@ -0,0 +1,11 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import {t as __t,} from "spacetimedb"; + +export default { + name: __t.string(), + sub: __t.string(), +}; diff --git a/common/bindings/stelo/revoke_account_member_reducer.ts b/common/bindings/stelo/revoke_account_member_reducer.ts new file mode 100644 index 00000000..62a7ceae --- /dev/null +++ b/common/bindings/stelo/revoke_account_member_reducer.ts @@ -0,0 +1,11 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import {t as __t,} from "spacetimedb"; + +export default { + accountId: __t.u64(), + memberId: __t.identity(), +}; diff --git a/common/bindings/stelo/revoke_account_tokens_reducer.ts b/common/bindings/stelo/revoke_account_tokens_reducer.ts new file mode 100644 index 00000000..185088ec --- /dev/null +++ b/common/bindings/stelo/revoke_account_tokens_reducer.ts @@ -0,0 +1,11 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import {t as __t,} from "spacetimedb"; + +export default { + accountId: __t.u64(), + tokenIds: __t.array(__t.u64()), +}; diff --git a/common/bindings/stelo/set_account_label_reducer.ts b/common/bindings/stelo/set_account_label_reducer.ts new file mode 100644 index 00000000..7ac87700 --- /dev/null +++ b/common/bindings/stelo/set_account_label_reducer.ts @@ -0,0 +1,11 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import {t as __t,} from "spacetimedb"; + +export default { + accountId: __t.u64(), + label: __t.option(__t.string()), +}; diff --git a/common/bindings/stelo/set_account_primary_reducer.ts b/common/bindings/stelo/set_account_primary_reducer.ts new file mode 100644 index 00000000..18c498b3 --- /dev/null +++ b/common/bindings/stelo/set_account_primary_reducer.ts @@ -0,0 +1,11 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import {t as __t,} from "spacetimedb"; + +export default { + accountId: __t.u64(), + primary: __t.bool(), +}; diff --git a/common/bindings/stelo/set_account_webhook_reducer.ts b/common/bindings/stelo/set_account_webhook_reducer.ts new file mode 100644 index 00000000..19275c00 --- /dev/null +++ b/common/bindings/stelo/set_account_webhook_reducer.ts @@ -0,0 +1,11 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import {t as __t,} from "spacetimedb"; + +export default { + accountId: __t.u64(), + webhook: __t.option(__t.string()), +}; diff --git a/common/bindings/stelo/types.ts b/common/bindings/stelo/types.ts new file mode 100644 index 00000000..94e0c549 --- /dev/null +++ b/common/bindings/stelo/types.ts @@ -0,0 +1,329 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import {type Infer as __Infer, t as __t,} from "spacetimedb"; + +export const Account = __t.object("Account", { + id: __t.u64(), + address: __t.string(), + label: __t.option(__t.string()), + webhook: __t.option(__t.string()), + userId: __t.identity(), + debitsPending: __t.u64(), + debitsPosted: __t.u64(), + creditsPending: __t.u64(), + creditsPosted: __t.u64(), + ledgerId: __t.u64(), + get kind() { + return AccountKind; + }, + createdAt: __t.timestamp(), +}); +export type Account = __Infer; + +// The tagged union or sum type for the algebraic type `AccountKind`. +export const AccountKind = __t.enum("AccountKind", { + Credit: __t.unit(), + Debit: __t.unit(), +}); +export type AccountKind = __Infer; + +export const AccountMember = __t.object("AccountMember", { + id: __t.u64(), + accountId: __t.u64(), + memberId: __t.identity(), + get kind() { + return MemberKind; + }, + get role() { + return Role; + }, + updatedAt: __t.timestamp(), + createdAt: __t.timestamp(), +}); +export type AccountMember = __Infer; + +export const AccountSearchHit = __t.object("AccountSearchHit", { + accountId: __t.u64(), + address: __t.string(), + ledgerId: __t.u64(), + get kind() { + return AccountKind; + }, + primaryUsername: __t.option(__t.string()), +}); +export type AccountSearchHit = __Infer; + +// The tagged union or sum type for the algebraic type `AccountSearchScope`. +export const AccountSearchScope = __t.enum("AccountSearchScope", { + Username: __t.unit(), + Address: __t.unit(), +}); +export type AccountSearchScope = __Infer; + +export const AccountToken = __t.object("AccountToken", { + id: __t.u64(), + accountId: __t.u64(), + token: __t.string(), + label: __t.string(), + createdBy: __t.identity(), + createdAt: __t.timestamp(), +}); +export type AccountToken = __Infer; + +export const App = __t.object("App", { + id: __t.identity(), + name: __t.string(), + nameNormalized: __t.string(), + createdBy: __t.identity(), + updatedAt: __t.timestamp(), + createdAt: __t.timestamp(), +}); +export type App = __Infer; + +export const AppSearchHit = __t.object("AppSearchHit", { + id: __t.identity(), + name: __t.string(), + ownerUsername: __t.option(__t.string()), +}); +export type AppSearchHit = __Infer; + +export const AppTicket = __t.object("AppTicket", { + id: __t.u64(), + expiresAt: __t.scheduleAt(), + createdBy: __t.identity(), + name: __t.string(), + sub: __t.string(), + get purpose() { + return AppTicketPurpose; + }, + createdAt: __t.timestamp(), +}); +export type AppTicket = __Infer; + +// The tagged union or sum type for the algebraic type `AppTicketPurpose`. +export const AppTicketPurpose = __t.enum("AppTicketPurpose", { + Create: __t.unit(), + Replace: __t.unit(), +}); +export type AppTicketPurpose = __Infer; + +export const Config = __t.object("Config", { + owner: __t.identity(), +}); +export type Config = __Infer; + +export const Ledger = __t.object("Ledger", { + id: __t.u64(), + name: __t.string(), + scale: __t.u8(), + get kind() { + return LedgerKind; + }, +}); +export type Ledger = __Infer; + +export const LedgerAuditRow = __t.object("LedgerAuditRow", { + ledgerId: __t.u64(), + ledgerName: __t.string(), + debitsNet: __t.i64(), + creditsNet: __t.i64(), + balanced: __t.bool(), +}); +export type LedgerAuditRow = __Infer; + +// The tagged union or sum type for the algebraic type `LedgerKind`. +export const LedgerKind = __t.enum("LedgerKind", { + Digital: __t.unit(), + Derivation: __t.unit(), + Physical: __t.unit(), +}); +export type LedgerKind = __Infer; + +// The tagged union or sum type for the algebraic type `MemberKind`. +export const MemberKind = __t.enum("MemberKind", { + User: __t.unit(), + App: __t.unit(), +}); +export type MemberKind = __Infer; + +export const MyAccountMemberRow = __t.object("MyAccountMemberRow", { + id: __t.u64(), + accountId: __t.u64(), + memberId: __t.identity(), + get kind() { + return MemberKind; + }, + name: __t.string(), + get role() { + return Role; + }, + updatedAt: __t.timestamp(), + createdAt: __t.timestamp(), +}); +export type MyAccountMemberRow = __Infer; + +export const MyAccountRow = __t.object("MyAccountRow", { + accountId: __t.u64(), + address: __t.string(), + label: __t.option(__t.string()), + get kind() { + return AccountKind; + }, + balance: __t.u64(), + ledgerId: __t.u64(), + ledgerName: __t.string(), + ledgerScale: __t.u8(), + get ledgerKind() { + return LedgerKind; + }, + get role() { + return Role; + }, + isPrimary: __t.bool(), + ownerUsername: __t.option(__t.string()), + webhook: __t.option(__t.string()), + createdAt: __t.timestamp(), +}); +export type MyAccountRow = __Infer; + +export const MyAccountTokenRow = __t.object("MyAccountTokenRow", { + id: __t.u64(), + accountId: __t.u64(), + label: __t.string(), + createdBy: __t.identity(), + createdAt: __t.timestamp(), +}); +export type MyAccountTokenRow = __Infer; + +export const MyAppRow = __t.object("MyAppRow", { + id: __t.identity(), + name: __t.string(), + createdAt: __t.timestamp(), +}); +export type MyAppRow = __Infer; + +export const MyAppTicketRow = __t.object("MyAppTicketRow", { + id: __t.u64(), + name: __t.string(), + get purpose() { + return AppTicketPurpose; + }, + expiresAt: __t.timestamp(), + createdAt: __t.timestamp(), +}); +export type MyAppTicketRow = __Infer; + +export const MyTransferRow = __t.object("MyTransferRow", { + id: __t.u64(), + debitAccountId: __t.u64(), + creditAccountId: __t.u64(), + debitAddress: __t.string(), + creditAddress: __t.string(), + debitUsername: __t.option(__t.string()), + creditUsername: __t.option(__t.string()), + pendingAmount: __t.option(__t.u64()), + postedAmount: __t.option(__t.u64()), + ledgerId: __t.u64(), + ledgerName: __t.string(), + ledgerScale: __t.u8(), + get kind() { + return TransferKind; + }, + get state() { + return TransferState; + }, + memo: __t.option(__t.string()), + createdAt: __t.timestamp(), + finalizedAt: __t.option(__t.timestamp()), +}); +export type MyTransferRow = __Infer; + +export const MyUserRow = __t.object("MyUserRow", { + id: __t.identity(), + bitcraftUsername: __t.string(), + bitcraftPlayerId: __t.u64(), + createdAt: __t.timestamp(), + isAdmin: __t.bool(), +}); +export type MyUserRow = __Infer; + +// The tagged union or sum type for the algebraic type `Role`. +export const Role = __t.enum("Role", { + Read: __t.unit(), + Write: __t.unit(), + Admin: __t.unit(), + Owner: __t.unit(), +}); +export type Role = __Infer; + +export const Transfer = __t.object("Transfer", { + id: __t.u64(), + debitAccountId: __t.u64(), + creditAccountId: __t.u64(), + pendingAmount: __t.option(__t.u64()), + postedAmount: __t.option(__t.u64()), + ledgerId: __t.u64(), + get kind() { + return TransferKind; + }, + get state() { + return TransferState; + }, + memo: __t.option(__t.string()), + createdAt: __t.timestamp(), + finalizedAt: __t.option(__t.timestamp()), +}); +export type Transfer = __Infer; + +export const TransferIdempotency = __t.object("TransferIdempotency", { + id: __t.u64(), + accountId: __t.u64(), + key: __t.string(), + transferId: __t.u64(), + requestHash: __t.string(), + createdAt: __t.timestamp(), +}); +export type TransferIdempotency = __Infer; + +// The tagged union or sum type for the algebraic type `TransferKind`. +export const TransferKind = __t.enum("TransferKind", { + Liability: __t.unit(), + Asset: __t.unit(), + Issue: __t.unit(), + Redeem: __t.unit(), +}); +export type TransferKind = __Infer; + +// The tagged union or sum type for the algebraic type `TransferState`. +export const TransferState = __t.enum("TransferState", { + Posted: __t.unit(), + Pending: __t.unit(), + PostPending: __t.unit(), + VoidPending: __t.unit(), +}); +export type TransferState = __Infer; + +export const User = __t.object("User", { + id: __t.identity(), + bitcraftUsername: __t.string(), + createdAt: __t.timestamp(), + isAdmin: __t.bool(), + bitcraftUsernameNormalized: __t.string(), + bitcraftPlayerId: __t.u64(), +}); +export type User = __Infer; + +export const WebhookDelivery = __t.object("WebhookDelivery", { + id: __t.u64(), + scheduledAt: __t.scheduleAt(), + accountId: __t.u64(), + transferId: __t.u64(), + url: __t.string(), + payloadJson: __t.string(), + attempts: __t.u32(), +}); +export type WebhookDelivery = __Infer; + diff --git a/common/bindings/stelo/types/procedures.ts b/common/bindings/stelo/types/procedures.ts new file mode 100644 index 00000000..35322f04 --- /dev/null +++ b/common/bindings/stelo/types/procedures.ts @@ -0,0 +1,22 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import {type Infer as __Infer} from "spacetimedb"; + +// Import all procedure arg schemas +import * as AccountLookupProcedure from "../account_lookup_procedure"; +import * as AccountSearchProcedure from "../account_search_procedure"; +import * as AppSearchProcedure from "../app_search_procedure"; +import * as CreateAccountTokenProcedure from "../create_account_token_procedure"; + +export type AccountLookupArgs = __Infer; +export type AccountLookupResult = __Infer; +export type AccountSearchArgs = __Infer; +export type AccountSearchResult = __Infer; +export type AppSearchArgs = __Infer; +export type AppSearchResult = __Infer; +export type CreateAccountTokenArgs = __Infer; +export type CreateAccountTokenResult = __Infer; + diff --git a/common/bindings/stelo/types/reducers.ts b/common/bindings/stelo/types/reducers.ts new file mode 100644 index 00000000..7bd90a86 --- /dev/null +++ b/common/bindings/stelo/types/reducers.ts @@ -0,0 +1,36 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import {type Infer as __Infer} from "spacetimedb"; + +// Import all reducer arg schemas +import CreateAccountReducer from "../create_account_reducer"; +import CreateAppTicketReducer from "../create_app_ticket_reducer"; +import CreateLedgerReducer from "../create_ledger_reducer"; +import CreateTransferReducer from "../create_transfer_reducer"; +import FinalizeTransferReducer from "../finalize_transfer_reducer"; +import GrantAccountMemberReducer from "../grant_account_member_reducer"; +import ReplaceAppTicketReducer from "../replace_app_ticket_reducer"; +import RevokeAccountMemberReducer from "../revoke_account_member_reducer"; +import RevokeAccountTokensReducer from "../revoke_account_tokens_reducer"; +import SetAccountLabelReducer from "../set_account_label_reducer"; +import SetAccountPrimaryReducer from "../set_account_primary_reducer"; +import SetAccountWebhookReducer from "../set_account_webhook_reducer"; +import UpdateAccountAddressReducer from "../update_account_address_reducer"; + +export type CreateAccountParams = __Infer; +export type CreateAppTicketParams = __Infer; +export type CreateLedgerParams = __Infer; +export type CreateTransferParams = __Infer; +export type FinalizeTransferParams = __Infer; +export type GrantAccountMemberParams = __Infer; +export type ReplaceAppTicketParams = __Infer; +export type RevokeAccountMemberParams = __Infer; +export type RevokeAccountTokensParams = __Infer; +export type SetAccountLabelParams = __Infer; +export type SetAccountPrimaryParams = __Infer; +export type SetAccountWebhookParams = __Infer; +export type UpdateAccountAddressParams = __Infer; + diff --git a/common/bindings/stelo/update_account_address_reducer.ts b/common/bindings/stelo/update_account_address_reducer.ts new file mode 100644 index 00000000..a0e221fe --- /dev/null +++ b/common/bindings/stelo/update_account_address_reducer.ts @@ -0,0 +1,11 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import {t as __t,} from "spacetimedb"; + +export default { + accountId: __t.u64(), + address: __t.string(), +}; diff --git a/common/bindings/stelo/user_table.ts b/common/bindings/stelo/user_table.ts new file mode 100644 index 00000000..d731d611 --- /dev/null +++ b/common/bindings/stelo/user_table.ts @@ -0,0 +1,15 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import {t as __t,} from "spacetimedb"; + +export default __t.row({ + id: __t.identity().primaryKey(), + bitcraftUsername: __t.string().name("bitcraft_username"), + createdAt: __t.timestamp().name("created_at"), + isAdmin: __t.bool().name("is_admin"), + bitcraftUsernameNormalized: __t.string().name("bitcraft_username_normalized"), + bitcraftPlayerId: __t.u64().name("bitcraft_player_id"), +}); diff --git a/common/crafts/currencies.ts b/common/crafts/currencies.ts new file mode 100644 index 00000000..b3d9f265 --- /dev/null +++ b/common/crafts/currencies.ts @@ -0,0 +1,27 @@ +/** + * currencies.ts - Framework-free source of truth for supported currency types/enums. + */ + +/** + * The bounty currencies a craft's payout can be denominated in. + */ +export const BOUNTY_CURRENCIES: readonly string[] = ["hex-coin", "stelo-hex-coin"]; +/** + * The currencies that are trust-based ledgers. Payers can make payouts to these without verification. + */ +export const MANUALLY_RECORDED_BOUNTY_CURRENCIES: readonly string[] = ["hex-coin"]; +/** + * The currencies whose payouts are backed by funds `brico-bot` holds in escrow on Stelo; the module + * requires a funded hold before such a bounty can be assigned. + */ +export const ESCROWED_BOUNTY_CURRENCIES: readonly string[] = ["stelo-hex-coin"]; + +export const CURRENCY_LABELS: Record = { + "hex-coin": "Hex Coin", + "stelo-hex-coin": "Stelo Hex Coin", +}; + +export const CURRENCY_EMOJI: Record = { + "hex-coin": "<:HexCoin:1555600295235559514>", + "stelo-hex-coin": "<:Stelo:1555606560028368936>", +}; \ No newline at end of file diff --git a/common/crafts/discord-template.ts b/common/crafts/discord-template.ts index ac672eb1..3056f443 100644 --- a/common/crafts/discord-template.ts +++ b/common/crafts/discord-template.ts @@ -2,7 +2,7 @@ * Substitutes `{token}`/`{token:format}` placeholders in a Discord display or notification template * against one craft's facts. Unrecognized tokens are left as literal text rather than throwing. */ -import {CURRENCY_EMOJI, CURRENCY_LABELS} from "./filter.ts"; +import {CURRENCY_EMOJI, CURRENCY_LABELS} from "./currencies"; /** Everything one craft's rendered template line might reference. */ export interface DiscordTemplateContext { diff --git a/common/crafts/entitlement.test.ts b/common/crafts/entitlement.test.ts index 1fcfb43f..39ee9f3d 100644 --- a/common/crafts/entitlement.test.ts +++ b/common/crafts/entitlement.test.ts @@ -1,6 +1,6 @@ import assert from "node:assert/strict"; import {describe, it} from "node:test"; -import {addRatio, advancePool, bonusFromMultiplier, computeEntitlement, fitFraction, multiplierFromBonus, parseDecimalRatio, reduceRatio} from "./entitlement.ts"; +import {addRatio, advancePool, bonusFromMultiplier, ceilRatio, computeEntitlement, escrowRequired, fitFraction, multiplierFromBonus, overlapRelease, parseDecimalRatio, reduceRatio, splitPayout} from "./entitlement.ts"; describe("computeEntitlement", () => { it("matches the worked example: ratio 1 Hex Coin per 20 effort", () => { @@ -153,10 +153,10 @@ describe("addRatio", () => { }); }); -describe("advancePool", () => { +describe("escrow pools", () => { const f = (numerator: bigint, denominator: bigint) => ({numerator, denominator}); - it("floors and carries like the single-pool rule", () => { + it("advancePool matches the single-pool floor/carry rule", () => { let carry = f(0n, 1n); let total = 0n; for (const delta of [f(3n, 5n), f(3n, 5n), f(1n, 2n)]) { @@ -168,20 +168,59 @@ describe("advancePool", () => { assert.deepEqual(carry, f(7n, 10n)); }); - it("rounds a carry that does not fit i64 down instead of overflowing", () => { + it("advancePool rounds a carry that does not fit i64 down instead of overflowing", () => { const {earned, remainder} = advancePool(f(1n, 2n ** 62n + 1n), f(1n, 2n ** 62n - 1n)); assert.equal(earned, 0n); assert.ok(remainder.denominator <= 2n ** 63n - 1n); assert.ok(remainder.numerator * (2n ** 62n + 1n) * (2n ** 62n - 1n) <= (2n ** 62n - 1n + 2n ** 62n + 1n) * remainder.denominator); }); - it("floors a negative delta so the carry stays in [0, 1)", () => { + it("advancePool floors a negative delta so the carry stays in [0, 1)", () => { const {earned, remainder} = advancePool(f(1n, 4n), f(-1n, 2n)); assert.equal(earned, -1n); assert.deepEqual(remainder, f(3n, 4n)); }); - it("fitFraction leaves a fitting fraction exact", () => { + it("fitFraction rounds up when asked", () => { + const big = f(1n, 2n ** 64n + 1n); + assert.ok(fitFraction(big, true).numerator >= fitFraction(big).numerator); assert.deepEqual(fitFraction(f(3n, 7n)), f(3n, 7n)); }); + + it("Alice/Bob/Carol: base sums stay within the hold, effective sums would not", () => { + // base 1/20, effective 11/200; efforts 130/130/140 against a 400-effort craft. + assert.equal(ceilRatio(f(400n, 20n)), 20n); + const rows = [130n, 130n, 140n].map(effort => ({ + G: advancePool(f(0n, 1n), reduceRatio(effort, 20n)), + F: advancePool(f(0n, 1n), reduceRatio(effort * 11n, 200n)), + })); + assert.deepEqual(rows.map(r => r.G.earned), [6n, 6n, 7n]); + assert.deepEqual(rows.map(r => r.F.earned), [7n, 7n, 7n]); + assert.deepEqual(rows.map(r => r.F.remainder), [f(3n, 20n), f(3n, 20n), f(7n, 10n)]); + assert.equal(rows.reduce((s, r) => s + r.G.earned, 0n), 19n); + }); + + it("escrowRequired: hold sizing, rate raise and cut", () => { + const zero = f(0n, 1n); + assert.equal(escrowRequired(zero, f(1n, 20n), 400n, 0n), 20n); + // 100 effort priced at 1/20 -> accrued 5; 300 remain. Raise to 1/15 needs 5 + 20 = 25. + assert.equal(escrowRequired(f(5n, 1n), f(1n, 15n), 400n, 100n), 25n); + // Cut to 1/30 needs 5 + 10 = 15. + assert.equal(escrowRequired(f(5n, 1n), f(1n, 30n), 400n, 100n), 15n); + // Over-priced effort adds nothing beyond accrued. + assert.equal(escrowRequired(f(21n, 2n), f(1n, 20n), 400n, 500n), 11n); + }); + + it("splitPayout pays the guaranteed part from escrow and the extras from the wallet", () => { + assert.deepEqual(splitPayout({total: 7n, guaranteedTotal: 6n, paidEscrow: 0n, paidDirect: 0n}), {owed: 7n, fromEscrow: 6n, fromWallet: 1n}); + assert.deepEqual(splitPayout({total: 7n, guaranteedTotal: 7n, paidEscrow: 0n, paidDirect: 0n}), {owed: 7n, fromEscrow: 7n, fromWallet: 0n}); + assert.deepEqual(splitPayout({total: 7n, guaranteedTotal: 7n, paidEscrow: 7n, paidDirect: 0n}), {owed: 0n, fromEscrow: 0n, fromWallet: 0n}); + }); + + it("overlapRelease reclassifies a direct payment that became guaranteed (r = 1)", () => { + assert.equal(overlapRelease({total: 7n, guaranteedTotal: 6n, paidEscrow: 6n, paidDirect: 1n}), 0n); + assert.equal(overlapRelease({total: 7n, guaranteedTotal: 7n, paidEscrow: 6n, paidDirect: 1n}), 1n); + // Never more than was actually paid directly. + assert.equal(overlapRelease({total: 7n, guaranteedTotal: 7n, paidEscrow: 0n, paidDirect: 0n}), 0n); + }); }); diff --git a/common/crafts/entitlement.ts b/common/crafts/entitlement.ts index 8f68dab2..2592b62d 100644 --- a/common/crafts/entitlement.ts +++ b/common/crafts/entitlement.ts @@ -90,32 +90,89 @@ export function parseDecimalRatio(input: string): {numerator: bigint; denominato return reduceRatio(BigInt(integerPart + fractionalPart), 10n ** BigInt(fractionalPart.length)); } +// --------------------------------------------------------------------------- +// Escrow: pooled fractional carry and the guaranteed/unsecured split +// --------------------------------------------------------------------------- + +export type Fraction = {numerator: bigint; denominator: bigint}; + const I64_MAX = 2n ** 63n - 1n; + +/** Throws if `value` doesn't fit a signed 64-bit column, so an oversized fraction fails loudly instead of trapping on write. */ +export function assertFitsI64(value: bigint): bigint { + if (value > I64_MAX || value < -I64_MAX - 1n) throw new RangeError(`value ${value} does not fit i64`); + return value; +} + +/** `ceil(numerator / denominator)` for a non-negative fraction. */ +export function ceilRatio(f: Fraction): bigint { + return (f.numerator + f.denominator - 1n) / f.denominator; +} + const FIT_DENOMINATOR = 10n ** 18n; /** * Returns a non-negative fraction that fits i64, unchanged when it already does. Otherwise it is - * rounded down to a `10^-18` grid — denominators multiply on every `addRatio` of unlike ratios and - * would otherwise outgrow the column, which wraps silently on write. + * rounded to a `10^-18` grid — down, or up with `roundUp` — since denominators multiply on every + * `addRatio` of unlike ratios and would otherwise outgrow the column. */ -export function fitFraction(f: {numerator: bigint; denominator: bigint}): {numerator: bigint; denominator: bigint} { +export function fitFraction(f: Fraction, roundUp = false): Fraction { if (f.numerator <= I64_MAX && f.denominator <= I64_MAX) return f; - return reduceRatio((f.numerator * FIT_DENOMINATOR) / f.denominator, FIT_DENOMINATOR); + const scaled = f.numerator * FIT_DENOMINATOR; + const numerator = roundUp ? (scaled + f.denominator - 1n) / f.denominator : scaled / f.denominator; + return reduceRatio(numerator, FIT_DENOMINATOR); } /** * Adds `delta` (an unfloored currency amount) to a pool's carried fraction, floors once, and * returns the whole units earned plus the new carry in `[0, 1)`, rounded down to fit i64 if needed. - * A negative `delta` (effort that dropped) can make `earned` negative. + * A negative `delta` (effort that dropped) can make `earned` negative. This is the one carry/floor + * implementation: a non-escrow pool and both of an escrow pool's sums (full and guaranteed) call it. */ -export function advancePool( - carry: {numerator: bigint; denominator: bigint}, - delta: {numerator: bigint; denominator: bigint}, -): {earned: bigint; remainder: {numerator: bigint; denominator: bigint}} { +export function advancePool(carry: Fraction, delta: Fraction): {earned: bigint; remainder: Fraction} { const combined = addRatio(carry, delta); let earned = combined.numerator / combined.denominator; // bigint division truncates toward zero; step down so the remainder is never negative. if (combined.numerator < 0n && earned * combined.denominator !== combined.numerator) earned -= 1n; const remainder = fitFraction(reduceRatio(combined.numerator - earned * combined.denominator, combined.denominator)); + assertFitsI64(earned); return {earned, remainder}; } + +/** + * What a craft's escrow must still hold to cover its guaranteed accrual at `ratio`: + * `ceil(accrued + ratio × (effortCovered − effortPriced))`. Effort priced beyond what the hold was + * sized for adds nothing. + */ +export function escrowRequired(accrued: Fraction, ratio: Fraction, effortCovered: bigint, effortPriced: bigint): bigint { + const remaining = effortCovered > effortPriced ? effortCovered - effortPriced : 0n; + return ceilRatio(addRatio(accrued, reduceRatio(ratio.numerator * remaining, ratio.denominator))); +} + +/** + * Splits what a pool still owes its payee: `fromEscrow` is backed by money already held, + * `fromWallet` (loyalty extras) is pulled from the paying wallet when it's paid. + */ +export function splitPayout(pool: {total: bigint; guaranteedTotal: bigint; paidEscrow: bigint; paidDirect: bigint}): { + owed: bigint; + fromEscrow: bigint; + fromWallet: bigint; +} { + const owed = pool.total - pool.paidEscrow - pool.paidDirect; + if (owed <= 0n) return {owed: 0n, fromEscrow: 0n, fromWallet: 0n}; + const guaranteedLeft = pool.guaranteedTotal - pool.paidEscrow; + const fromEscrow = guaranteedLeft <= 0n ? 0n : guaranteedLeft < owed ? guaranteedLeft : owed; + return {owed, fromEscrow, fromWallet: owed - fromEscrow}; +} + +/** + * Units paid directly that have since become guaranteed (a payee's guaranteed total grew past what + * is still owed): they move from `paidDirect` to `paidEscrow`, and the paying wallet is owed the + * same amount back from escrow. + */ +export function overlapRelease(pool: {total: bigint; guaranteedTotal: bigint; paidEscrow: bigint; paidDirect: bigint}): bigint { + const owed = pool.total - pool.paidEscrow - pool.paidDirect; + const stillOwed = owed > 0n ? owed : 0n; + const r = pool.guaranteedTotal - pool.paidEscrow - stillOwed; + return r > 0n ? (r < pool.paidDirect ? r : pool.paidDirect) : 0n; +} diff --git a/common/crafts/errors.ts b/common/crafts/errors.ts index 74716d91..218df4e4 100644 --- a/common/crafts/errors.ts +++ b/common/crafts/errors.ts @@ -141,6 +141,50 @@ export const CraftError = { // Discord guild install /** Dynamic — carries the rejected value as its `:`-suffix. `upsertDiscordGuildInstall`'s `commandMode` must be one of `@brico/crafts/discord-guild`'s `DISCORD_COMMAND_MODES`. */ UNKNOWN_COMMAND_MODE: 'UNKNOWN_COMMAND_MODE', + + // Stelo escrow + /** A reducer that only applies to an escrowed currency (`ESCROWED_BOUNTY_CURRENCIES`) was given another one. Dynamic — carries the currency as its `:`-suffix. */ + CURRENCY_NOT_ESCROWED: 'CURRENCY_NOT_ESCROWED', + /** `upsertBountyRule`/`upsertCraftBountyOverride` — an escrowed currency needs a `fundingLinkId`. */ + FUNDING_WALLET_REQUIRED: 'FUNDING_WALLET_REQUIRED', + /** `upsertBountyRule`/`upsertCraftBountyOverride` — `fundingLinkId` only applies to an escrowed currency. */ + FUNDING_WALLET_NOT_ALLOWED: 'FUNDING_WALLET_NOT_ALLOWED', + /** The named Stelo wallet isn't an active wallet of the caller on the bounty's currency (unlinked, someone else's, or on another ledger). */ + INVALID_FUNDING_WALLET: 'INVALID_FUNDING_WALLET', + /** `assignCraftBounty` for an escrowed currency with no open escrow hold of the same payer and currency for the craft. */ + ESCROW_REQUIRED: 'ESCROW_REQUIRED', + /** `assignCraftBounty` with a ratio above what the craft's escrow secures. */ + ESCROW_RATIO_EXCEEDED: 'ESCROW_RATIO_EXCEEDED', + /** `upsertCraftBountyEntitlement` — the effective ratio must be at least the base ratio. */ + EFFECTIVE_RATIO_BELOW_BASE: 'EFFECTIVE_RATIO_BELOW_BASE', + /** `recordSteloTransferIntent` — the craft already has an open escrow row or an unsettled hold. */ + ESCROW_ALREADY_FUNDED: 'ESCROW_ALREADY_FUNDED', + NO_OPEN_ESCROW: 'NO_OPEN_ESCROW', + UNKNOWN_STELO_TRANSFER: 'UNKNOWN_STELO_TRANSFER', + /** `recordSteloTransferIntent` — an argument doesn't match what the books allow for that purpose. Dynamic — carries the reason as its `:`-suffix. */ + INVALID_STELO_TRANSFER: 'INVALID_STELO_TRANSFER', + UNKNOWN_ESCROW_POOL: 'UNKNOWN_ESCROW_POOL', + /** A payout for this pool and source is already unsettled. */ + PAYOUT_ALREADY_PENDING: 'PAYOUT_ALREADY_PENDING', + NOTHING_OWED: 'NOTHING_OWED', + /** `settleUnclaimedPayee` — the payee has a receiving wallet for the currency, or the pool hasn't been flagged unclaimed. */ + PAYEE_NOT_UNCLAIMED: 'PAYEE_NOT_UNCLAIMED', + /** `settleUnclaimedPayee` — the rollover currency must be one of `MANUALLY_RECORDED_BOUNTY_CURRENCIES`. */ + ROLLOVER_CURRENCY_NOT_MANUAL: 'ROLLOVER_CURRENCY_NOT_MANUAL', + /** `registerSteloWallet`/`setSteloReceivingWallet` — the link isn't an active `stelo` link of that account. */ + NOT_AN_ACTIVE_STELO_LINK: 'NOT_AN_ACTIVE_STELO_LINK', + /** `submitIntegrationLinkCandidate` — wrong provider, someone else's request, or an empty address. */ + INVALID_LINK_CANDIDATE: 'INVALID_LINK_CANDIDATE', + /** `noteIntegrationLinkExternal` for a `stelo` request whose `externalId` isn't the submitted candidate. */ + LINK_CANDIDATE_MISMATCH: 'LINK_CANDIDATE_MISMATCH', + /** `noteIntegrationLinkExternal` for an unverified `stelo` link the browser has not confirmed yet. */ + LINK_NOT_CONFIRMED: 'LINK_NOT_CONFIRMED', + /** A funding wallet must be one the bot has verified the linker still administers (`stelo_wallet_status.verified`). */ + FUNDING_WALLET_UNVERIFIED: 'FUNDING_WALLET_UNVERIFIED', + /** `setCraftBountyFunding` — `state`, `source` or `reason` outside the `@brico/crafts/stelo` allow-lists. Dynamic — carries the field name as its `:`-suffix. */ + INVALID_FUNDING_STATUS: 'INVALID_FUNDING_STATUS', + FUNDING_NOTIFY_TRIGGER_BELONGS_TO_ANOTHER_ACCOUNT: 'FUNDING_NOTIFY_TRIGGER_BELONGS_TO_ANOTHER_ACCOUNT', + DISCORD_FUNDING_NOTIFY_TARGET_BELONGS_TO_ANOTHER_ACCOUNT: 'DISCORD_FUNDING_NOTIFY_TARGET_BELONGS_TO_ANOTHER_ACCOUNT', } as const; export type CraftErrorCode = (typeof CraftError)[keyof typeof CraftError]; diff --git a/common/crafts/filter.ts b/common/crafts/filter.ts index b69563b2..e0763606 100644 --- a/common/crafts/filter.ts +++ b/common/crafts/filter.ts @@ -15,6 +15,8 @@ // ── Fields ──────────────────────────────────────────────────── +import {BOUNTY_CURRENCIES, CURRENCY_LABELS} from "./currencies"; + /** Every property of a craft a filter can test. */ export type FilterField = | "region" @@ -89,25 +91,6 @@ export const CLAIM_ACCESS_FLAG_LABELS: Record = { owner: "Owner", }; -/** - * The bounty currencies a craft's payout can be denominated in. - */ -export const BOUNTY_CURRENCIES: readonly string[] = ["hex-coin"]; - -/** - * The currencies that are trust-based ledgers. Payers can make payouts to these without verification. - */ -export const MANUALLY_RECORDED_BOUNTY_CURRENCIES: readonly string[] = ["hex-coin"]; - -export const CURRENCY_LABELS: Record = { - "hex-coin": "Hex Coin", -}; - -export const CURRENCY_EMOJI: Record = { - "hex-coin": "<:HexCoin:1555600295235559514>", - "stelo-hex-coin": "<:Stelo:1555606560028368936>", -}; - export type FilterValue = string | number | boolean; export interface FieldMeta { diff --git a/common/crafts/notification.test.ts b/common/crafts/notification.test.ts new file mode 100644 index 00000000..0ae3cdde --- /dev/null +++ b/common/crafts/notification.test.ts @@ -0,0 +1,23 @@ +import assert from "node:assert/strict"; +import {describe, it} from "node:test"; +import {encodeNotificationPayload, type NotificationPayload, parseNotificationPayload} from "./notification.ts"; + +describe("parseNotificationPayload", () => { + it("round-trips craft and funding payloads", () => { + const craft: NotificationPayload = { + tag: "craft", + value: {watchId: "w", filterName: "f", eventKind: "added", craftId: "42", recipeId: 7, regionName: "R", claimName: "C"}, + }; + const funding: NotificationPayload = {tag: "funding", value: {kind: "funding-failed", role: "payer", currency: "stelo", amount: "12"}}; + assert.deepEqual(parseNotificationPayload(encodeNotificationPayload(craft)), craft); + assert.deepEqual(parseNotificationPayload(encodeNotificationPayload(funding)), funding); + }); + + it("discards unknown tags, bad shapes and malformed JSON", () => { + assert.equal(parseNotificationPayload(JSON.stringify({tag: "bounty", value: {}})), null); + assert.equal(parseNotificationPayload(JSON.stringify({tag: "funding", value: {kind: "x"}})), null); + assert.equal(parseNotificationPayload(JSON.stringify({tag: "craft"})), null); + assert.equal(parseNotificationPayload("not json"), null); + assert.equal(parseNotificationPayload("null"), null); + }); +}); diff --git a/common/crafts/notification.ts b/common/crafts/notification.ts new file mode 100644 index 00000000..35974530 --- /dev/null +++ b/common/crafts/notification.ts @@ -0,0 +1,84 @@ +/** + * Notification payload shapes stored as JSON in `notification_v2.payloadJson` (`{tag, value}`), + * and the tolerant reader the frontend uses. `u64`/`i64` fields travel as decimal strings. + */ + +/** A craft-sourced notification (`tag: "craft"`). */ +export interface CraftNotificationPayload { + /** `craft_filter_watch.filterId` at fire time — a loose join key, not an FK. */ + watchId: string; + filterName: string; + /** 'added' | 'finished' | 'removed'. */ + eventKind: string; + craftId: string; + recipeId: number; + regionName: string; + claimName?: string; + ownerName?: string; +} + +/** A funding alert (`tag: "funding"`): ids and machine codes only, never display text. */ +export interface FundingNotificationPayload { + /** One of `FUNDING_NOTIFICATION_KINDS`. */ + kind: string; + /** 'payer' | 'payee'. */ + role: string; + currency: string; + craftId?: string; + amount?: string; + reason?: string; + /** Display names of the parties of unclaimed money, as of when it was moved. */ + payerName?: string; + payeeName?: string; +} + +export type NotificationPayload = + | {tag: "craft"; value: CraftNotificationPayload} + | {tag: "funding"; value: FundingNotificationPayload}; + +export function encodeNotificationPayload(payload: NotificationPayload): string { + return JSON.stringify(payload); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function str(v: unknown): v is string { + return typeof v === "string"; +} + +function optStr(v: unknown): v is string | undefined { + return v === undefined || typeof v === "string"; +} + +/** + * Parses a stored payload. Returns `null` for malformed JSON, an unrecognized tag, or a recognized + * tag whose value has the wrong shape, so callers can skip what they don't understand. + */ +export function parseNotificationPayload(json: string): NotificationPayload | null { + let raw: unknown; + try { + raw = JSON.parse(json); + } catch { + return null; + } + if (!isRecord(raw) || !isRecord(raw.value)) return null; + const v = raw.value; + switch (raw.tag) { + case "craft": + if (str(v.watchId) && str(v.filterName) && str(v.eventKind) && str(v.craftId) && typeof v.recipeId === "number" + && str(v.regionName) && optStr(v.claimName) && optStr(v.ownerName)) { + return {tag: "craft", value: v as unknown as CraftNotificationPayload}; + } + return null; + case "funding": + if (str(v.kind) && str(v.role) && str(v.currency) && optStr(v.craftId) && optStr(v.amount) && optStr(v.reason) + && optStr(v.payerName) && optStr(v.payeeName)) { + return {tag: "funding", value: v as unknown as FundingNotificationPayload}; + } + return null; + default: + return null; + } +} diff --git a/common/crafts/package.json b/common/crafts/package.json index 57ebc3d4..83fb8dc5 100644 --- a/common/crafts/package.json +++ b/common/crafts/package.json @@ -11,11 +11,14 @@ "./names": "./names.ts", "./entitlement": "./entitlement.ts", "./errors": "./errors.ts", + "./currencies": "./currencies.ts", "./discord-display": "./discord-display.ts", "./discord-guild": "./discord-guild.ts", "./discord-template": "./discord-template.ts", "./discord-notify": "./discord-notify.ts", - "./icon-render": "./icon-render.ts" + "./icon-render": "./icon-render.ts", + "./stelo": "./stelo.ts", + "./notification": "./notification.ts" }, "scripts": { "test": "node --test \"*.test.ts\"", diff --git a/common/crafts/stelo.test.ts b/common/crafts/stelo.test.ts new file mode 100644 index 00000000..9cbd66c9 --- /dev/null +++ b/common/crafts/stelo.test.ts @@ -0,0 +1,43 @@ +import assert from "node:assert/strict"; +import {describe, it} from "node:test"; + +import {describeFundingReason, FUNDING_REASONS, normalizeSteloAddress, renderFundingTemplate} from "./stelo.ts"; + +describe("normalizeSteloAddress", () => { + it("strips one leading # and upper-cases", () => { + assert.equal(normalizeSteloAddress("#abcd"), "ABCD"); + assert.equal(normalizeSteloAddress(" Zq "), "ZQ"); + }); + + it("rejects input that is empty once trimmed and de-hashed", () => { + for (const bad of ["", " ", "#", " # "]) assert.equal(normalizeSteloAddress(bad), null, bad); + }); + + it("accepts custom addresses", () => { + assert.equal(normalizeSteloAddress("#payer001"), "PAYER001"); + assert.equal(normalizeSteloAddress("a-b_c"), "A-B_C"); + assert.equal(normalizeSteloAddress("ABCDEFGHIJKLMNOPQRSTUVWXYZ"), "ABCDEFGHIJKLMNOPQRSTUVWXYZ"); + assert.equal(normalizeSteloAddress("##AB"), "#AB"); + }); +}); + +describe("renderFundingTemplate", () => { + it("fills known tokens, renders the reason in English and keeps unknown tokens", () => { + const text = renderFundingTemplate("{mention}{craft} {reason} {bounties} {payer}>{payee} {nope}", { + currency: "X", amount: null, craft: "[c]()", bounties: "[b]()", wallet: "#A", reason: "cannot-pull", + payer: "Ann", payee: "Bob", mentionText: null, + }); + assert.equal(text, "[c]() Brico.app needs Write access on the funding wallet [b]() Ann>Bob {nope}"); + }); + + it("has clause-style English for every reason code and the unclaimed outcomes", () => { + for (const code of FUNDING_REASONS) { + const text = describeFundingReason(code); + assert.notEqual(text, code, `${code} has no wording`); + assert.match(text, /^[a-zA-Z]/); + assert.doesNotMatch(text, /\.$/, `${code} wording must not end in a period`); + } + assert.equal(describeFundingReason("rolled-over"), "rolled over"); + assert.equal(describeFundingReason("mystery"), "mystery"); + }); +}); diff --git a/common/crafts/stelo.ts b/common/crafts/stelo.ts new file mode 100644 index 00000000..93e0cf81 --- /dev/null +++ b/common/crafts/stelo.ts @@ -0,0 +1,149 @@ +/** + * Stelo wallet vocabulary shared by the module, the bot and the frontend: account addresses, the + * funding-status allow-lists and the funding-alert kinds and default wording. + */ + +/** + * Normalizes a pasted Stelo address: trims, drops one leading `#` and ASCII-upper-cases, as Stelo's + * account search does. Addresses may be custom, so any non-empty remainder is accepted; `null` if empty. + */ +export function normalizeSteloAddress(input: string): string | null { + let address = input.trim(); + if (address.startsWith("#")) address = address.slice(1); + address = address.replace(/[a-z]+/g, (s) => s.toUpperCase()); + return address === "" ? null : address; +} + +/** `craft_bounty_funding.state`. */ +export const FUNDING_STATES = ["waiting", "funding", "failed", "funded"] as const; +export type FundingState = (typeof FUNDING_STATES)[number]; + +/** `craft_bounty_funding.source`. */ +export const FUNDING_SOURCES = ["override", "rule"] as const; +export type FundingSource = (typeof FUNDING_SOURCES)[number]; + +/** Machine codes for `craft_bounty_funding.reason` and funding notifications. */ +export const FUNDING_REASONS = [ + "no-wallet", + "wallet-inactive", + "cannot-pull", + "not-authorized", + "stelo-unavailable", + "insufficient-funds", + "no-permission", + "invalid-account", +] as const; +export type FundingReason = (typeof FUNDING_REASONS)[number]; + +/** Reasons a payer can act on; entering `waiting` with one of these raises a funding alert. */ +export const ACTIONABLE_FUNDING_REASONS: readonly string[] = [ + "cannot-pull", "not-authorized", "wallet-inactive", "insufficient-funds", "no-permission", "invalid-account", +]; + +/** The payer's bounty overview page, which funding alerts point at. */ +export const MY_BOUNTIES_PATH = "/account/my-bounties"; + +/** `FundingNotificationPayload.kind`. */ +export const FUNDING_NOTIFICATION_KINDS = ["funding-failed", "payout-shortfall", "unclaimed-rolled-over", "unclaimed-returned"] as const; +export type FundingNotificationKind = (typeof FUNDING_NOTIFICATION_KINDS)[number]; + +/** `FundingNotificationPayload.role`. */ +export const FUNDING_NOTIFICATION_ROLES = ["payer", "payee"] as const; + +/** The three on/off events of a `funding_notify_trigger`. */ +export const FUNDING_NOTIFY_EVENTS = ["fundingFailed", "payoutShortfall", "unclaimed"] as const; +export type FundingNotifyEvent = (typeof FUNDING_NOTIFY_EVENTS)[number]; + +/** The event whose flag enables a notification of `kind`. */ +export function fundingEventOf(kind: FundingNotificationKind): FundingNotifyEvent { + switch (kind) { + case "funding-failed": + return "fundingFailed"; + case "payout-shortfall": + return "payoutShortfall"; + default: + return "unclaimed"; + } +} + +/** + * Default Discord wording per event. Tokens: `{currency}`, `{amount}`, `{craft}` (link, empty without + * a craft), `{bounties}` (link to the payer's bounties page), `{wallet}`, `{reason}`, `{payer}` and + * `{payee}` (empty when the event has none) and `{mention}`. + */ +export const DEFAULT_FUNDING_NOTIFY_TEMPLATE: Record = { + fundingFailed: "{mention} Couldn't fund your bounties in {currency} from {wallet}: {reason}. {bounties}", + payoutShortfall: "{mention} Couldn't pull {amount} {currency} of payouts from {wallet}: {reason}.", + unclaimed: "{mention} {amount} {currency} owed by {payer} to {payee} went unclaimed and was moved: {reason}.", +}; + +/** + * English wording for each `FundingReason`, as a clause that continues a sentence. The frontend's + * translated copy (`funding-vocab.ts`) is this text capitalized with a trailing period. + */ +export const FUNDING_REASON_TEXT: Record = { + "no-wallet": "no funding wallet is chosen", + "wallet-inactive": "the funding wallet is no longer linked", + "cannot-pull": "Brico.app needs Write access on the funding wallet", + "not-authorized": "the account that linked the funding wallet is no longer an Admin or Owner of it", + "stelo-unavailable": "Stelo is unavailable right now", + "insufficient-funds": "the funding wallet doesn't have enough funds", + "no-permission": "Brico.app lost permission on the funding wallet", + "invalid-account": "the funding wallet account is invalid", +}; + +/** What happened to unclaimed money, in the `{reason}` slot of an `unclaimed` message. */ +export const UNCLAIMED_OUTCOME_TEXT = { + "rolled-over": "rolled over", + "returned": "returned to the payer", +} as const; + +/** The wording for a reason code or unclaimed outcome; an unknown code is returned as is. */ +export function describeFundingReason(code: string): string { + return (FUNDING_REASON_TEXT as Record)[code] + ?? (UNCLAIMED_OUTCOME_TEXT as Record)[code] + ?? code; +} + +export interface FundingTemplateContext { + currency: string; + amount: string | null; + /** A craft link, or `null` when the event has none. */ + craft: string | null; + /** A link to the payer's bounties page. */ + bounties: string | null; + wallet: string | null; + reason: string | null; + /** Display names of the two parties of unclaimed money. */ + payer: string | null; + payee: string | null; + mentionText: string | null; +} + +/** Substitutes the funding tokens; an unknown token is left as literal text. */ +export function renderFundingTemplate(template: string, ctx: FundingTemplateContext): string { + return template.replace(/\{([^{}]+)}/g, (placeholder, token: string) => { + switch (token) { + case "currency": + return ctx.currency; + case "amount": + return ctx.amount ?? ""; + case "craft": + return ctx.craft ?? ""; + case "bounties": + return ctx.bounties ?? ""; + case "wallet": + return ctx.wallet ?? ""; + case "reason": + return ctx.reason ? describeFundingReason(ctx.reason) : ""; + case "payer": + return ctx.payer ?? ""; + case "payee": + return ctx.payee ?? ""; + case "mention": + return ctx.mentionText ?? ""; + default: + return placeholder; + } + }); +} diff --git a/frontend/public/Stelo.svg b/frontend/public/Stelo.svg new file mode 100644 index 00000000..f2ab997f --- /dev/null +++ b/frontend/public/Stelo.svg @@ -0,0 +1,61 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/frontend/src/app.css b/frontend/src/app.css index 6ee6ce43..0b11f3e6 100644 --- a/frontend/src/app.css +++ b/frontend/src/app.css @@ -33,17 +33,17 @@ --destructive: 345, 66.7%, 48%; --destructive-foreground: 0, 0%, 98%; - --info: 204 94% 94%; - --info-foreground: 199 89% 48%; + --info: 199 89% 48%; + --info-foreground: 204 94% 94%; --success: 140.5, 82%, 39.2%; --success-foreground: 149 80% 90%; - --warning: 48 96% 89%; - --warning-foreground: 50, 100%, 40%; + --warning: 50, 100%, 40%; + --warning-foreground: 48 96% 89%; - --error: 0 93% 94%; - --error-foreground: 345, 66.7%, 48%; + --error: 345, 66.7%, 48%; + --error-foreground: 0 93% 94%; --ring: 203, 70.9%, 28.7%; diff --git a/frontend/src/app.tsx b/frontend/src/app.tsx index 69623308..cc6e81a1 100644 --- a/frontend/src/app.tsx +++ b/frontend/src/app.tsx @@ -17,6 +17,7 @@ import {AccountProvider} from "~/lib/account/state"; import {useGameDataReady, useLoadingProgress} from "~/lib/bitcraft-data"; import {isBotUserAgent} from "~/lib/bot-detect"; import {FilterSyncProvider} from "~/lib/crafts/filter-sync"; +import {useWalletCurrencyAutoAdd} from "~/lib/crafts/shown-currencies"; import {setActiveDataLocale} from "~/lib/data-translation"; import {GlobalSearchProvider} from "~/lib/global-search-context"; import {activateUILocale, i18n} from "~/lib/i18n"; @@ -30,6 +31,7 @@ function AppRoot(props: { children: any }) { sidebarStartsCollapsed, colorStorageManager, midnightDark, resolvedUILocale, resolvedDataLocale, } = useSettings(); + useWalletCurrencyAutoAdd(); // `allReady` gates the app UI behind a loading screen until every table has settled, and // also triggers the client-side search index build. For bots the data is already loaded at diff --git a/frontend/src/components/account/SteloWalletsCard.tsx b/frontend/src/components/account/SteloWalletsCard.tsx new file mode 100644 index 00000000..c2a53454 --- /dev/null +++ b/frontend/src/components/account/SteloWalletsCard.tsx @@ -0,0 +1,295 @@ +/** + * SteloWalletsCard.tsx — the "Stelo wallets" card on `/account/profile`: the caller's linked wallets + * (receiving wallet per currency, whether the bot can fund from each) and the address-based link flow. + */ +import type {LinkedIntegration} from "@brico/bindings/brico-app/types"; +import {msg} from "@lingui/core/macro"; +import {useLingui} from "@lingui/solid"; +import {Trans} from "@lingui/solid/macro"; +import {createEffect, createMemo, createSignal, For, on, Show} from "solid-js"; +import {Badge} from "~/components/ui/badge"; +import {Button} from "~/components/ui/button"; +import {Card, CardContent, CardDescription, CardHeader, CardTitle} from "~/components/ui/card"; +import {Checkbox} from "~/components/ui/checkbox"; +import {Collapsible, CollapsibleContent} from "~/components/ui/collapsible"; +import {RadioGroup, RadioGroupItem, RadioGroupItemLabel} from "~/components/ui/radio-group"; +import {TextField, TextFieldErrorMessage, TextFieldInput, TextFieldLabel} from "~/components/ui/text-field"; +import {showToast} from "~/components/ui/toast"; +import {type SteloWalletView, useLinkedIntegrations} from "~/lib/account/links"; +import {checkSteloAddress, groupWalletsByCurrency, linkKey, linkOutcome, type PendingReasonKey, pendingReasonKey} from "~/lib/account/stelo-link-flow"; +import {describeServerError} from "~/lib/crafts/error-vocab"; +import {CurrencyLabel} from "~/lib/crafts/filter-condition"; + +const STELO_PROVIDER = "stelo"; + +const PENDING_REASON_COPY: Record> = { + "checking": msg`Checking…`, + "bot-not-member": msg`Brico.app is not on that account (or the address is wrong). Add it under Permissions and this retries by itself.`, + "ambiguous-address": msg`That address exists on more than one ledger. Use the account's address on the Hex Coin ledger.`, + "not-debit": msg`That account can't be linked as a wallet: it isn't a debit account.`, + "wrong-ledger": msg`That account can't be linked as a wallet: it isn't on a supported ledger.`, + "escrow-account": msg`That account can't be linked as a wallet: it is Brico's own escrow account.`, + "no-linked-bitcraft-player": msg`Link your BitCraft account above first.`, + "no-owner-admin-match": msg`None of your linked characters is an Owner or Admin of that account on Stelo.`, + "unknown-address": msg`No account with that address exists on a supported ledger.`, + "confirm-unverified": msg`Found an account. Confirm it below to link it.`, + "verified": msg`Verified. Linking…`, + "unknown": msg`That account can't be linked yet.`, +}; + +function WalletRow(props: {wallet: SteloWalletView; onUnlink: (link: LinkedIntegration) => void}) { + return ( +
+ + + #{props.wallet.address} + Checking…} + > + Unverified: can receive, not fund} + > + Read only: can receive, not fund} + > + Can fund bounties + + + + + + +
+ ); +} + +export function SteloWalletsCard(props: {onUnlink: (link: LinkedIntegration) => void}) { + const {_} = useLingui(); + const { + links, pendingRequests, steloWallets, unregisteredSteloLinks, beginLink, + submitSteloCandidate, confirmLinkCandidate, cancelLinkRequest, setReceivingWallet, + } = useLinkedIntegrations(); + + const groups = createMemo(() => groupWalletsByCurrency(steloWallets())); + const [open, setOpen] = createSignal(false); + const [input, setInput] = createSignal(""); + const [submitting, setSubmitting] = createSignal(false); + const [unverified, setUnverified] = createSignal(false); + const check = createMemo(() => checkSteloAddress(input())); + + // The wait is whatever the module holds: a stelo request with a candidate, so it survives a reload. + const waitingRequest = createMemo(() => + pendingRequests().find(r => r.provider === STELO_PROVIDER && r.externalId === undefined && r.candidateAddress !== undefined) + ); + const isWaiting = createMemo(() => waitingRequest() !== undefined); + const steloLinkKeys = () => new Set(links().filter(l => l.provider === STELO_PROVIDER).map(linkKey)); + + let linkKeysBefore = new Set(); + let alreadyLinked = false; + let wasWaiting = false; + createEffect(on(isWaiting, waiting => { + const outcome = linkOutcome({wasWaiting, isWaiting: waiting, alreadyLinked, linkKeysBefore, linkKeysNow: steloLinkKeys()}); + if (waiting && !wasWaiting) { + linkKeysBefore = steloLinkKeys(); + const candidate = waitingRequest()?.candidateAddress; + alreadyLinked = links().some(l => l.provider === STELO_PROVIDER && l.externalHandle === candidate); + } + wasWaiting = waiting; + if (outcome === "success") { + showToast({title: () => Wallet linked, variant: "success"}); + setOpen(false); + setInput(""); + } else if (outcome === "already-linked") { + showToast({title: () => Wallet already linked}); + setOpen(false); + setInput(""); + } else if (outcome === "timeout") { + showToast({title: () => Linking timed out, try again, variant: "error"}); + } + })); + + function reportError(e: unknown) { + console.error(e); + showToast({title: () => Something went wrong, description: () => describeServerError(e), variant: "error"}); + } + + async function link() { + const address = check(); + if (address.state !== "valid") return; + setSubmitting(true); + let code: string | undefined; + try { + code = await beginLink(STELO_PROVIDER); + await submitSteloCandidate(code, address.address, unverified()); + } catch (e) { + reportError(e); + if (code) await cancelLinkRequest(code).catch(() => {}); + } finally { + setSubmitting(false); + } + } + + async function cancel() { + const request = waitingRequest(); + if (!request) return; + try { + await cancelLinkRequest(request.code); + } catch (e) { + reportError(e); + } + } + + async function confirm() { + const request = waitingRequest(); + if (!request) return; + try { + await confirmLinkCandidate(request.code); + } catch (e) { + reportError(e); + } + } + + async function paste() { + try { + setInput(await navigator.clipboard.readText()); + } catch (e) { + console.error(e); + } + } + + return ( + + + Stelo wallets + + Link Stelo wallets to fund craft bounties and to receive payouts and refunds. + + + + + {group => ( +
+

+ +

+ Pick a receiving wallet: payouts and refunds can't be sent until you do. +

+
+ w.isReceiving)?.link.id.toString()} + onChange={value => { + if (value) setReceivingWallet(group.currency, BigInt(value)).catch(reportError); + }} + aria-label={_(msg`Receiving wallet`)} + > + + {wallet => } + + +

The selected wallet receives payouts and refunds.

+
+ )} +
+ + + {link => ( +
+

#{link.externalHandle ?? link.externalId} Registering…

+ +
+ )} +
+ + +

No wallets linked.

+
+ + +
+

Link new wallet

+ +
+ +
    +
  1. + Go to your Stelo account. +
  2. +
  3. (Optional) As a payer, create a separate account just for crafts to limit funds and permissions.
  4. +
  5. + + Open the account, then Add Permission, App, and add the app{" "} + Brico.app · wiz. + Payers must grant Write permissions to the app. Receiving payouts only + needs Read, or use the option below to skip verification. + +
  6. +
  7. Copy the account address (starts with #) and paste it here.
  8. +
  9. Press Link.
  10. +
+ + + Stelo account address +
+ setInput(e.currentTarget.value)} + /> + + + +
+ Enter the account address, like #ABCDEF. +
+ + setUnverified(checked)} + label={Link without verifying. Skips read access and ownership checks from the app, but can only be used for receiving payouts.} + /> + +
+ + + + +
+ + {request => ( + {_(PENDING_REASON_COPY[pendingReasonKey(request().pendingReason)])}

} + > +
+

+ + Found #{request().candidateAddress} + {name => <> owned by {name()}}. + Payouts will be sent to this account without verifying you own it. + +

+
+ +
+
+
+ )} +
+
+
+
+
+ ); +} diff --git a/frontend/src/components/crafts/CurrencyFilterButton.tsx b/frontend/src/components/crafts/CurrencyFilterButton.tsx new file mode 100644 index 00000000..7168e55d --- /dev/null +++ b/frontend/src/components/crafts/CurrencyFilterButton.tsx @@ -0,0 +1,29 @@ +import {msg} from "@lingui/core/macro"; +import {useLingui} from "@lingui/solid"; +import {createMemo, Show} from "solid-js"; +import {ValueFilterPopover} from "~/components/data-table/value-filter-popover"; +import {currencyVocab} from "~/lib/crafts/filter-condition"; +import {useLabel} from "~/lib/labels"; + +/** + * Multi-select currency filter for the reports. `selected` is owned by the page (empty means all + * currencies); hidden when there are fewer than two currencies to choose between. + */ +export function CurrencyFilterButton(props: {currencies: readonly string[]; selected: readonly string[]; onChange: (selected: string[]) => void}) { + const {_} = useLingui(); + const label = useLabel(); + const options = createMemo(() => props.currencies.map(currency => { + const vocab = currencyVocab(currency); + return { + value: currency, + label: vocab ? label(vocab.label) : currency, + icon: vocab ? () => vocab.icon() : undefined, + }; + })); + + return ( + = 2}> + + + ); +} diff --git a/frontend/src/components/crafts/CurrencySelect.tsx b/frontend/src/components/crafts/CurrencySelect.tsx index 5ffe5759..5530cfcd 100644 --- a/frontend/src/components/crafts/CurrencySelect.tsx +++ b/frontend/src/components/crafts/CurrencySelect.tsx @@ -1,19 +1,28 @@ -import {BOUNTY_CURRENCIES} from "@brico/crafts/filter"; +import {Show} from "solid-js"; import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from "~/components/ui/select.tsx"; +import {useShownCurrencies} from "~/lib/crafts/shown-currencies"; import {CurrencyLabel} from "~/lib/crafts/filter-condition.tsx"; +import {cn} from "~/lib/utils"; -export function CurrencySelect(props: { value: string; onChange: (value: string) => void }) { +/** Offers the shown currencies plus the current value; with a single option it is just the label. */ +export function CurrencySelect(props: { value: string; onChange: (value: string) => void; class?: string }) { + const options = useShownCurrencies(() => [props.value]); return ( - + + ); -} \ No newline at end of file +} diff --git a/frontend/src/components/crafts/CurrencyVisibilitySelect.tsx b/frontend/src/components/crafts/CurrencyVisibilitySelect.tsx new file mode 100644 index 00000000..3618436c --- /dev/null +++ b/frontend/src/components/crafts/CurrencyVisibilitySelect.tsx @@ -0,0 +1,33 @@ +import {BOUNTY_CURRENCIES} from "@brico/crafts/currencies"; +import {Trans} from "@lingui/solid/macro"; +import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from "~/components/ui/select.tsx"; +import {CurrencyLabel} from "~/lib/crafts/filter-condition.tsx"; +import {useSettings} from "~/lib/settings"; + +/** Multi-select for which bounty currencies the site offers; unchecking the last one is ignored. */ +export function CurrencyVisibilitySelect() { + const settings = useSettings(); + return ( + + multiple + options={[...BOUNTY_CURRENCIES]} + value={settings.visibleCurrencies()} + onChange={values => { + if (values.length > 0) settings.setVisibleCurrencies(values); + }} + itemComponent={itemProps => } + > + + > + {state => { + const selected = state.selectedOptions(); + return selected.length === 1 + ? + : {selected.length} currencies; + }} + + + + + ); +} diff --git a/frontend/src/components/crafts/FilterBuilder.tsx b/frontend/src/components/crafts/FilterBuilder.tsx index d6380895..52b0bac4 100644 --- a/frontend/src/components/crafts/FilterBuilder.tsx +++ b/frontend/src/components/crafts/FilterBuilder.tsx @@ -36,6 +36,7 @@ import { TbOutlineTrash as IconRemove, } from "solid-icons/tb"; import {createEffect, createMemo, createSignal, For, Match, Show, Switch} from "solid-js"; +import {CurrencySelect} from "~/components/crafts/CurrencySelect"; import {TierIcon} from "~/components/shared/GameIcon"; import {Badge} from "~/components/ui/badge"; import {Button} from "~/components/ui/button"; @@ -46,7 +47,7 @@ import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from "~/c import {Switch as Toggle, SwitchControl as ToggleControl, SwitchLabel as ToggleLabel, SwitchThumb as ToggleThumb} from "~/components/ui/switch"; import {Rarities} from "~/lib/bitcraft-utils"; import {ConditionPhrase} from "~/lib/crafts/filter-condition"; -import {cmpLabel, fieldLabel, quantifierLabel} from "~/lib/crafts/filter-vocab"; +import {cmpLabel, currencyData, fieldLabel, quantifierLabel} from "~/lib/crafts/filter-vocab"; import {useLabel} from "~/lib/labels"; import {useSettings} from "~/lib/settings"; import {cn, useCopy} from "~/lib/utils"; @@ -443,7 +444,14 @@ function LeafRow(props: { const label = useLabel(); const fieldChoices = () => FILTER_FIELDS.filter(field => !props.disallowedFields?.includes(field)); const meta = () => FIELDS[props.leaf.field]; - const fieldOptions = () => props.options(props.leaf.field); + // A saved filter on a currency the user has since hidden must still display, so the leaf's own values are added back. + const fieldOptions = () => { + const options = props.options(props.leaf.field); + if (!options || props.leaf.field !== "currency") return options; + const own = (Array.isArray(props.leaf.value) ? props.leaf.value : [props.leaf.value]) + .filter(value => !options.some(option => option.value === value)); + return [...options, ...own.map(value => ({value, label: currencyData(String(value)) ? label(currencyData(String(value))!.label) : String(value)}))]; + }; const wantsList = () => wantsListValue(props.leaf.cmp); const isBool = () => meta().kind === "bool"; const isQuantified = () => meta().quantified === true; @@ -524,6 +532,12 @@ function LeafRow(props: { props.onChange({...props.leaf, value})}/> + + props.onChange({...props.leaf, value})} + /> + {options => ( = { + fundingFailed: msg`Funding failed`, + payoutShortfall: msg`Payout shortfall`, + unclaimed: msg`Unclaimed money moved`, +}; + +/** + * Funding alerts: which of the three Stelo funding events notify through the in-site toast and each + * linked Discord channel, plus per-channel wording. Independent of any saved filter. Render only + * while logged in. + */ +export function FundingAlertsCard() { + const {_} = useLingui(); + const settings = createFundingNotifySettings(); + const [expanded, setExpanded] = createSignal(null); + + const setDiscordEvent = (sinkId: string, event: FundingNotifyEvent, checked: boolean) => { + settings.setFlag(sinkId, event, checked); + // Prefill the default wording when enabling an event that has none. + if (checked && !settings.templatesFor(sinkId)[event]) settings.setTemplate(sinkId, event, DEFAULT_FUNDING_NOTIFY_TEMPLATE[event]); + }; + + return ( + + + Funding alerts + + + Get told when a bounty can't be funded from your Stelo wallet, a payout can't be pulled, or unclaimed money is moved. + Discord channels appear once linked via /notify link. + + + + +
+
+ + Toast + + {sink => {sink.channelName ?? sink.channelId}} + + + Settings + + + {sink => ( + + )} + + + + {event => ( + <> + {_(EVENT_LABELS[event])} + settings.setFlag(null, event, checked)}/> + + {sink => setDiscordEvent(sink.id, event, checked)}/>} + + + )} + +
+
+ + {sink => ( + + +

+ Mention: {settings.mentionFor(sink.id)?.name ?? _(msg`(none)`)} + {" — "} + set via /funding notify-setup in Discord. +

+ + {event => ( + + {_(EVENT_LABELS[event])} +
+ settings.setTemplate(sink.id, event, e.currentTarget.value)} + /> + +
+
+ )} +
+
+
+ )} +
+ {error =>

{error()}

}
+
+
+ ); +} diff --git a/frontend/src/components/crafts/FundingStatus.tsx b/frontend/src/components/crafts/FundingStatus.tsx new file mode 100644 index 00000000..6f734edb --- /dev/null +++ b/frontend/src/components/crafts/FundingStatus.tsx @@ -0,0 +1,107 @@ +import {Trans} from "@lingui/solid/macro"; +import {A} from "@solidjs/router"; +import {createMemo, Show} from "solid-js"; +import {PayoutRateButton} from "~/components/crafts/PayoutRateButton"; +import {Badge} from "~/components/ui/badge"; +import {Button} from "~/components/ui/button"; +import {Popover, PopoverContent, PopoverTrigger} from "~/components/ui/popover"; +import {useLinkedIntegrations} from "~/lib/account/links"; +import type {CraftBountyFundingHandle} from "~/lib/crafts/bounty-funding"; +import {fundingStatusView, type Ratio} from "~/lib/crafts/funding-status"; +import {FUNDING_STATE_LABEL, fundingReasonText} from "~/lib/crafts/funding-vocab"; +import {useLabel} from "~/lib/labels"; + +const rateOf = (ratio: Ratio) => Number(ratio.numerator) / Number(ratio.denominator); + +/** + * The payer's view of an escrowed bounty's funding on the craft detail page. Renders nothing for a + * craft with no escrowed-currency intent. + */ +export function FundingStatus(props: {craftId: bigint; assigned: Ratio | null; funding: CraftBountyFundingHandle}) { + const label = useLabel(); + const links = useLinkedIntegrations(); + + const row = createMemo(() => props.funding.funding().get(props.craftId)); + const escrow = createMemo(() => props.funding.openEscrows().get(props.craftId)); + const view = createMemo(() => fundingStatusView(row(), props.assigned, escrow())); + const wallet = createMemo(() => { + const linkId = view()?.fundingLinkId; + return linkId === undefined ? undefined : links.steloWallets().find(candidate => candidate.link.id === linkId); + }); + const refundDue = createMemo(() => { + const current = wallet(); + const currency = row()?.currency; + if (!current || !currency) return undefined; + return props.funding.escrowWallets().find(entry => entry.currency === currency && entry.fundingAccountId.toString() === current.link.externalId)?.refundDue; + }); + const lastFailure = createMemo(() => props.funding.failedTransfers() + .filter(transfer => (transfer.purpose.tag === "Hold" || transfer.purpose.tag === "TopUp") && transfer.purpose.value.craftId === props.craftId) + .sort((a, b) => (a.id < b.id ? 1 : -1))[0]); + + return ( + + {current => ( +
+
+ + {label(FUNDING_STATE_LABEL[current().state])} + + from override}> + from rule + + {found => #{found().address}} + + } variant="ghost" size="sm" class="h-6 px-2 text-xs">Details + + No funds are held for this craft yet.

}> + {held => ( + <> +

Held{held().heldAmount.toString()}

+

Released{held().releasedAmount.toString()}

+ + )} +
+ +

Refund due{refundDue()!.toString()}

+
+ + {reason => ( +

+ Last failure:{" "} + {fundingReasonText(reason(), label)} +

+ )} +
+
+
+
+ + {reason => ( +

+ {fundingReasonText(reason(), label)} + {" "}Retries automatically. +

+ )} +
+ + {ratio => ( +

+ Raising to + +

+ )} +
+ +

Held for crafters: {current().spendable!.toString()}

+
+ +

+ Intended rate + +

+
+
+ )} +
+ ); +} diff --git a/frontend/src/components/crafts/FundingWalletSelect.tsx b/frontend/src/components/crafts/FundingWalletSelect.tsx new file mode 100644 index 00000000..b37952a5 --- /dev/null +++ b/frontend/src/components/crafts/FundingWalletSelect.tsx @@ -0,0 +1,96 @@ +import {Trans} from "@lingui/solid/macro"; +import {A} from "@solidjs/router"; +import {createEffect, createMemo, Show, untrack} from "solid-js"; +import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from "~/components/ui/select.tsx"; +import {type SteloWalletView, useLinkedIntegrations} from "~/lib/account/links"; +import {fundingWalletState, isEscrowCurrency, walletForCurrency} from "~/lib/crafts/funding-wallet"; + +function WalletName(props: {wallet: SteloWalletView}) { + return ( + + #{props.wallet.address} + receiving + read only + + ); +} + +/** + * Picks which of the caller's Stelo wallets funds a bounty. Renders nothing for a currency without + * escrow. A single wallet is pre-selected; several are never guessed. The module validates the choice. + */ +export function FundingWalletSelect(props: {currency: string; value: bigint | null; onChange: (value: bigint | null) => void}) { + const links = useLinkedIntegrations(); + const allWallets = createMemo(() => links.steloWalletsFor(props.currency)); + // Only a wallet the bot verified the caller administers can fund; the current choice stays listed so it can be replaced. + const wallets = createMemo(() => allWallets().filter(wallet => wallet.verified === true || wallet.link.id === props.value)); + const choices = (list: readonly SteloWalletView[]) => list.map(wallet => ({linkId: wallet.link.id, currency: wallet.currency})); + const state = createMemo(() => fundingWalletState(props.currency, props.value, choices(allWallets()))); + const selected = createMemo(() => wallets().find(wallet => wallet.link.id === props.value)); + const excluded = createMemo(() => allWallets().filter(wallet => wallet.verified !== true && wallet.link.id !== props.value).length); + + // The pre-selected wallet as a string, so the effect reruns only when it changes. + const autoPick = createMemo(() => { + if (!links.walletsReady() || props.value !== null) return ""; + return walletForCurrency(props.currency, null, choices(allWallets().filter(wallet => wallet.verified === true)))?.toString() ?? ""; + }); + createEffect(() => { + const pick = autoPick(); + // Untracked: the caller's onChange reads and writes its own draft, which would otherwise re-run this effect. + if (pick !== "") untrack(() => props.onChange(BigInt(pick))); + }); + + return ( + + 0 || state() === "invalid"} + fallback={ + + None of your wallets are verified to fund bounties yet. Link one with Brico.app on it, on the profile page.} + > + Link a Stelo wallet first. + + + } + > +
+ + value={selected()} + options={wallets()} + optionValue={wallet => wallet.link.id.toString()} + optionTextValue={wallet => `#${wallet.address}`} + onChange={wallet => wallet && wallet.link.id !== props.value && props.onChange(wallet.link.id)} + placeholder={Funding wallet} + itemComponent={itemProps => } + > + + > + {current => ( + Unlinked wallet}> + + + )} + + + + + + This wallet is no longer linked. Choose another. + + + + This wallet is no longer verified: the account that linked it must be an Admin or Owner of it on Stelo. + + + + + Brico.app needs Write access on this wallet before it can fund the bounty. + + +
+
+
+ ); +} diff --git a/frontend/src/components/crafts/LocalGroupingsManager.tsx b/frontend/src/components/crafts/LocalGroupingsManager.tsx index ac02c4f4..9a44abaa 100644 --- a/frontend/src/components/crafts/LocalGroupingsManager.tsx +++ b/frontend/src/components/crafts/LocalGroupingsManager.tsx @@ -2,8 +2,8 @@ * LocalGroupingsManager.tsx — lets a payer group BitCraft player ids under an arbitrary display * name of their own, purely for local aggregation on `/account/payees` (edited from * `/account/settings`, not from the payees page itself — see that page's doc comment). This is a - * secondary lookup layered on top of the brico-account grouping the backend already resolves (see - * `applyLocalGroupings` in `~/lib/crafts/entitlement-reports`): a payee who hasn't linked or shared + * lookup that takes precedence over the brico-account grouping the backend resolves (see + * `groupPayerRows` in `~/lib/crafts/entitlement-reports`): a payee who hasn't linked or shared * a brico account still shows up as its own row otherwise, and not every payer wants that. * * Entirely client-side — stored in `LocalPayeeGrouping[]` (`~/lib/settings`), never touches the diff --git a/frontend/src/components/crafts/PayoutHistoryDialog.tsx b/frontend/src/components/crafts/PayoutHistoryDialog.tsx index ab84b1e8..71810fb0 100644 --- a/frontend/src/components/crafts/PayoutHistoryDialog.tsx +++ b/frontend/src/components/crafts/PayoutHistoryDialog.tsx @@ -4,22 +4,25 @@ * ledger behind `bounty_payout_record.paidTotal`. * * `filter === null` shows every row the caller can see (the toolbar's "History" button); a - * non-null filter narrows to one table row's own (currency, player id set) — and, on the payee - * side, one payer — matching the "row's own scope" the per-row history icon opens. A row that - * represents a multi-player group already carries every one of its sub-rows' player ids, since the - * transactions themselves are recorded per-player, not per-group. + * non-null filter narrows to one table row's own (player, currency) pairs — and, on the payee + * side, one payer — matching the "row's own scope" the per-row history icon opens. A group row + * carries every one of its sub-rows' pairs, since the transactions themselves are recorded + * per-player and currency, not per-group. */ import type {BountyPayoutLogRow} from "@brico/bindings/brico-app/types"; +import {useLingui} from "@lingui/solid"; import {Trans} from "@lingui/solid/macro"; import {createMemo, For, Show} from "solid-js"; +import {Badge} from "~/components/ui/badge"; import {Dialog, DialogContent, DialogHeader, DialogTitle} from "~/components/ui/dialog"; import {Table, TableBody, TableCell, TableHead, TableHeader, TableRow} from "~/components/ui/table"; import {CurrencyLabel} from "~/lib/crafts/filter-condition"; +import {payoutSourceLabel} from "~/lib/crafts/payout-source"; import {uiLocale} from "~/lib/i18n"; export interface PayoutHistoryFilter { - currency: string; - playerIds: readonly string[]; + /** The (player, currency) pairs the row covers; a group row can span several currencies. */ + entries: readonly {playerId: string; currency: string}[]; /** Only set on the payee side, where one caller can have transactions with several payers — * narrows to one payer's rows alongside the (currency, playerIds) scope above. */ payerAccountIdentity?: string; @@ -36,14 +39,14 @@ export function PayoutHistoryDialog(props: { filter: PayoutHistoryFilter | null; playerName: (playerId: string) => string; }) { + const {_} = useLingui(); const filtered = createMemo(() => { const filter = props.filter; - const playerIds = filter ? new Set(filter.playerIds) : null; + const entries = filter ? new Set(filter.entries.map(entry => `${entry.playerId}:${entry.currency}`)) : null; const rows = filter === null ? props.rows : props.rows.filter(row => - row.currency === filter.currency - && playerIds!.has(row.payeePlayerId.toString()) + entries!.has(`${row.payeePlayerId}:${row.currency}`) && (filter.payerAccountIdentity === undefined || row.payerAccountIdentity.toHexString() === filter.payerAccountIdentity) ); return [...rows].sort((a, b) => Number(b.createdAt.microsSinceUnixEpoch - a.createdAt.microsSinceUnixEpoch)); @@ -87,6 +90,9 @@ export function PayoutHistoryDialog(props: { {row.amount > 0n ? "+" : ""}{row.amount.toString()} + + {text => {text()}} + diff --git a/frontend/src/components/crafts/PayoutScheduleLine.tsx b/frontend/src/components/crafts/PayoutScheduleLine.tsx new file mode 100644 index 00000000..f8768498 --- /dev/null +++ b/frontend/src/components/crafts/PayoutScheduleLine.tsx @@ -0,0 +1,37 @@ +import {Trans} from "@lingui/solid/macro"; +import {Show} from "solid-js"; +import {createNextPayout} from "~/lib/crafts/payout-schedule"; +import {uiLocale} from "~/lib/i18n"; + +function relativeTime(targetMs: number): string { + const minutes = Math.round((targetMs - Date.now()) / 60_000); + const formatter = new Intl.RelativeTimeFormat(uiLocale(), {numeric: "auto"}); + if (Math.abs(minutes) < 90) return formatter.format(minutes, "minute"); + if (Math.abs(minutes) < 60 * 36) return formatter.format(Math.round(minutes / 60), "hour"); + return formatter.format(Math.round(minutes / (60 * 24)), "day"); +} + +const dateText = (ms: number) => new Date(ms).toLocaleString(uiLocale()); + +/** + * The next Stelo payout sweep, shown while `show` (some row is in an escrowed currency). Renders + * nothing when the bot has published no schedule. + */ +export function PayoutScheduleLine(props: {show: boolean}) { + const next = createNextPayout(); + return ( + + {time => ( +

+ Payout due since {dateText((time() as {sinceMs: number}).sinceMs)}} + keyed + > + {atMs => Next Stelo payout: {relativeTime(atMs)} ({dateText(atMs)})} + +

+ )} +
+ ); +} diff --git a/frontend/src/components/data-table/table-faceted-filter.tsx b/frontend/src/components/data-table/table-faceted-filter.tsx index 71d489a7..734fcebb 100644 --- a/frontend/src/components/data-table/table-faceted-filter.tsx +++ b/frontend/src/components/data-table/table-faceted-filter.tsx @@ -16,6 +16,7 @@ import {Slider, SliderFill, SliderLabel, SliderThumb, SliderTrack, SliderValueLa import {type Label, useLabel} from "~/lib/labels"; import {cn, ensurePagesVisible} from "~/lib/utils" import {Switch, SwitchControl, SwitchLabel, SwitchThumb} from "../ui/switch" +import {ValueFilterPopover} from "./value-filter-popover" type OptionBase = { @@ -439,284 +440,215 @@ export function TableFacetedFilter(props: TableFacetedFilterProps) : (props.column?.getFilterValue() ?? []) as [number, number]; }; - const [search, setSearch] = createSignal(""); const [popoverOpen, setPopoverOpen] = createSignal(false); const [editingWithNumberInputs, setEditingWithNumberInputs] = createSignal(false); let sliderEditMin!: HTMLInputElement | null; let sliderEditMax!: HTMLInputElement | null; - const filteredOptions = () => { - let s = search(); - if (!isValueBased()) return []; - const o = resolvedOptions() as ValueBasedOption[]; - s = s.trim().toLowerCase() - if (!s) return o; - return o.filter((option) => { - const label = option.label.toLowerCase(); - if (label.includes(s)) { - return true; - } - }); - } - return ( - { - setPopoverOpen(open); - setEditingWithNumberInputs(false); - }} - > - } variant="outline" size="sm" onclick={() => setPopoverOpen(!popoverOpen())} - class={`h-8 border-dashed ${selectedValues().length ? "border-muted-foreground border-solid" : ""}`} + { + setPopoverOpen(open); + setEditingWithNumberInputs(false); + }} > - - {title()} - - - - {/* Stats-based badges */} - {(() => { - const keys = selectedValues() as string[]; - const statsOpts = resolvedOptions() as StatsBasedOption; - return ( - - }> - - - - ); - })()} - - - - {selectedValues()[0] + "-" + selectedValues()[1]} - - - - - {selectedValues().length} - -