Skip to content

Commit 5ee5b05

Browse files
committed
fix: harden address handling and share arcade formatters
1 parent deb4c3a commit 5ee5b05

File tree

9 files changed

+292
-117
lines changed

9 files changed

+292
-117
lines changed

.claude-modified-files

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,3 +108,12 @@
108108
/Users/valentindosimont/www/c7e/arcade/client/src/components/ui/dashboard/GameCard.stories.tsx
109109
/Users/valentindosimont/www/c7e/arcade/client/src/components/ui/dashboard/GameCard.stories.tsx
110110
/Users/valentindosimont/www/c7e/arcade/client/tsconfig.app.json
111+
/Users/valentindosimont/.claude/plans/lazy-forging-hejlsberg.md
112+
/Users/valentindosimont/www/c7e/arcade/client/src/effect/atoms/arcade.ts
113+
/Users/valentindosimont/www/c7e/arcade/client/src/effect/atoms/arcade.ts
114+
/Users/valentindosimont/www/c7e/arcade/client/src/effect/atoms/arcade.ts
115+
/Users/valentindosimont/.claude/plans/lazy-forging-hejlsberg.md
116+
/Users/valentindosimont/www/c7e/arcade/client/src/effect/layers/arcade.ts
117+
/Users/valentindosimont/.claude/plans/zany-questing-lemon.md
118+
/Users/valentindosimont/www/c7e/arcade/client/src/effect/atoms/arcade.ts
119+
/Users/valentindosimont/.claude/plans/zany-questing-lemon.md

client/src/effect/atoms/arcade.ts

Lines changed: 93 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,100 @@
1-
import { createEntityQueryWithUpdatesAtom } from "@dojoengine/react/effect";
2-
import { ARCADE_MODELS, mainnetConfig, toriiRuntime } from "../layers/arcade";
1+
import {
2+
createEntityQueryWithUpdatesAtom,
3+
ToriiGrpcClient,
4+
mergeFormatters,
5+
parseEntities,
6+
} from "@dojoengine/react/effect";
7+
import { Atom, Result } from "@effect-atom/atom-react";
8+
import { Effect, Stream, Schedule } from "effect";
9+
import {
10+
ARCADE_MODELS,
11+
arcadeFormatters,
12+
mainnetConfig,
13+
toriiRuntime,
14+
} from "../layers/arcade";
315
import { KeysClause, ToriiQueryBuilder } from "@dojoengine/sdk";
416

517
const clause = KeysClause([], [], "VariableLen").build();
6-
export const arcadeAtom = createEntityQueryWithUpdatesAtom(
18+
const query = new ToriiQueryBuilder()
19+
.withClause(clause)
20+
.withEntityModels(ARCADE_MODELS)
21+
.includeHashedKeys()
22+
.withLimit(100000000);
23+
24+
const subscriptionAtom = createEntityQueryWithUpdatesAtom(
725
toriiRuntime,
8-
new ToriiQueryBuilder()
9-
.withClause(clause)
10-
.withEntityModels(ARCADE_MODELS)
11-
.includeHashedKeys()
12-
.withLimit(100000) as unknown as Parameters<
13-
typeof createEntityQueryWithUpdatesAtom
14-
>[1],
26+
query as unknown as Parameters<typeof createEntityQueryWithUpdatesAtom>[1],
1527
clause,
1628
[mainnetConfig.manifest.world.address],
29+
arcadeFormatters,
1730
);
31+
32+
const POLL_INTERVAL = 10_000;
33+
34+
const pollingAtom = toriiRuntime
35+
.atom(
36+
Stream.unwrap(
37+
Effect.gen(function* () {
38+
const { use, formatters } = yield* ToriiGrpcClient;
39+
const merged = mergeFormatters(formatters, arcadeFormatters);
40+
const built = query.build();
41+
42+
return Stream.asyncScoped<any>((push) =>
43+
Effect.gen(function* () {
44+
const poll = () =>
45+
use((client) => client.getEntities(built)).pipe(
46+
Effect.flatMap(parseEntities(merged)),
47+
Effect.tap((result) => push.single(result)),
48+
Effect.catchAll(() => Effect.void),
49+
);
50+
51+
yield* poll();
52+
53+
const timer = setInterval(
54+
() => Effect.runPromise(poll()),
55+
POLL_INTERVAL,
56+
);
57+
yield* Effect.addFinalizer(() =>
58+
Effect.sync(() => clearInterval(timer)),
59+
);
60+
}),
61+
);
62+
}),
63+
).pipe(Stream.retry(Schedule.exponential("1 second", 2))),
64+
{ initialValue: null as any },
65+
)
66+
.pipe(Atom.keepAlive);
67+
68+
export const arcadeAtom = Atom.make((get: any) => {
69+
const sub = get(subscriptionAtom);
70+
const poll: any = get(pollingAtom);
71+
72+
if (poll !== null && poll._tag === "Success" && poll.value !== null) {
73+
const pollData: any = poll.value;
74+
if (pollData?.items?.length > 0) {
75+
if (sub._tag !== "Success" || !(sub.value as any)?.items?.length) {
76+
return Result.success({
77+
items: pollData.items,
78+
entitiesMap: new Map(pollData.items.map((i: any) => [i.entityId, i])),
79+
next_cursor: pollData.next_cursor,
80+
});
81+
}
82+
83+
const subVal: any = sub.value;
84+
const merged = new Map<string, any>();
85+
for (const item of pollData.items) {
86+
merged.set(item.entityId, item);
87+
}
88+
for (const item of subVal.items) {
89+
merged.set(item.entityId, item);
90+
}
91+
return Result.success({
92+
items: Array.from(merged.values()),
93+
entitiesMap: merged,
94+
next_cursor: pollData.next_cursor ?? subVal.next_cursor,
95+
});
96+
}
97+
}
98+
99+
return sub;
100+
}).pipe(Atom.keepAlive) as unknown as typeof subscriptionAtom;

client/src/effect/atoms/users.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,14 @@ export const accountByAddressAtom = Atom.family(
102102
(address: string | undefined) => {
103103
if (!address) return nullResultAtom;
104104

105-
const checksumAddress = getChecksumAddress(address);
105+
const checksumAddress = (() => {
106+
try {
107+
return getChecksumAddress(address);
108+
} catch {
109+
return null;
110+
}
111+
})();
112+
if (!checksumAddress) return nullResultAtom;
106113

107114
return accountsAtom.pipe(
108115
Atom.map((result) =>

client/src/effect/layers/arcade.ts

Lines changed: 82 additions & 80 deletions
Original file line numberDiff line numberDiff line change
@@ -66,90 +66,92 @@ export const ARCADE_MODELS = [
6666
"ARCADE-Follow",
6767
];
6868

69+
export const arcadeFormatters = {
70+
models: {
71+
"ARCADE-Game": (m: any, ctx: any) => ({
72+
type: "game",
73+
identifier: ctx.entityId,
74+
data: GameModel.from(ctx.entityId, m),
75+
}),
76+
"ARCADE-Edition": (m: any, ctx: any) => ({
77+
type: "edition",
78+
identifier: ctx.entityId,
79+
data: EditionModel.from(ctx.entityId, m),
80+
}),
81+
"ARCADE-Access": (m: any, ctx: any) => ({
82+
type: "access",
83+
identifier: ctx.entityId,
84+
data: AccessModel.from(ctx.entityId, m),
85+
}),
86+
"ARCADE-CollectionEdition": (m: any, ctx: any) => ({
87+
type: "collectionEdition",
88+
identifier: ctx.entityId,
89+
data: CollectionEditionModel.from(ctx.entityId, m),
90+
}),
91+
"ARCADE-Order": (m: any, ctx: any) => ({
92+
type: "order",
93+
identifier: ctx.entityId,
94+
data: OrderModel.from(ctx.entityId, m as any),
95+
}),
96+
"ARCADE-Book": (m: any, ctx: any) => ({
97+
type: "book",
98+
identifier: ctx.entityId,
99+
data: BookModel.from(ctx.entityId, m as any),
100+
}),
101+
"ARCADE-Moderator": (m: any, ctx: any) => ({
102+
type: "moderator",
103+
identifier: ctx.entityId,
104+
data: ModeratorModel.from(ctx.entityId, m as any),
105+
}),
106+
"ARCADE-Alliance": (m: any, ctx: any) => ({
107+
type: "alliance",
108+
identifier: ctx.entityId,
109+
data: AllianceModel.from(ctx.entityId, m),
110+
}),
111+
"ARCADE-Guild": (m: any, ctx: any) => ({
112+
type: "guild",
113+
identifier: ctx.entityId,
114+
data: GuildModel.from(ctx.entityId, m),
115+
}),
116+
"ARCADE-Member": (m: any, ctx: any) => ({
117+
type: "member",
118+
identifier: ctx.entityId,
119+
data: MemberModel.from(ctx.entityId, m),
120+
}),
121+
"ARCADE-Listing": (m: any, ctx: any) => ({
122+
type: "listing",
123+
key: ctx.entityId,
124+
data: ListingEvent.from(ctx.entityId, m as any),
125+
}),
126+
"ARCADE-Offer": (m: any, ctx: any) => ({
127+
type: "offer",
128+
key: ctx.entityId,
129+
data: OfferEvent.from(ctx.entityId, m as any),
130+
}),
131+
"ARCADE-Sale": (m: any, ctx: any) => ({
132+
type: "sale",
133+
key: ctx.entityId,
134+
data: SaleEvent.from(ctx.entityId, m as any),
135+
}),
136+
"ARCADE-TrophyPinning": (m: any, ctx: any) => ({
137+
type: "pin",
138+
key: ctx.entityId,
139+
data: PinEvent.from(ctx.entityId, m),
140+
}),
141+
"ARCADE-Follow": (m: any, ctx: any) => ({
142+
type: "follow",
143+
key: ctx.entityId,
144+
data: FollowEvent.from(ctx.entityId, m),
145+
}),
146+
},
147+
} as const;
148+
69149
const toriiLayer = makeToriiLayer(
70150
{ manifest: mainnetConfig.manifest, toriiUrl: getToriiUrl(DEFAULT_PROJECT) },
71151
{
72-
autoReconnect: false,
152+
autoReconnect: true,
73153
maxReconnectAttempts: 5,
74-
formatters: {
75-
models: {
76-
"ARCADE-Game": (m, ctx) => ({
77-
type: "game",
78-
identifier: ctx.entityId,
79-
data: GameModel.from(ctx.entityId, m),
80-
}),
81-
"ARCADE-Edition": (m, ctx) => ({
82-
type: "edition",
83-
identifier: ctx.entityId,
84-
data: EditionModel.from(ctx.entityId, m),
85-
}),
86-
"ARCADE-Access": (m, ctx) => ({
87-
type: "access",
88-
identifier: ctx.entityId,
89-
data: AccessModel.from(ctx.entityId, m),
90-
}),
91-
"ARCADE-CollectionEdition": (m, ctx) => ({
92-
type: "collectionEdition",
93-
identifier: ctx.entityId,
94-
data: CollectionEditionModel.from(ctx.entityId, m),
95-
}),
96-
"ARCADE-Order": (m, ctx) => ({
97-
type: "order",
98-
identifier: ctx.entityId,
99-
data: OrderModel.from(ctx.entityId, m as any),
100-
}),
101-
"ARCADE-Book": (m, ctx) => ({
102-
type: "book",
103-
identifier: ctx.entityId,
104-
data: BookModel.from(ctx.entityId, m as any),
105-
}),
106-
"ARCADE-Moderator": (m, ctx) => ({
107-
type: "moderator",
108-
identifier: ctx.entityId,
109-
data: ModeratorModel.from(ctx.entityId, m as any),
110-
}),
111-
"ARCADE-Alliance": (m, ctx) => ({
112-
type: "alliance",
113-
identifier: ctx.entityId,
114-
data: AllianceModel.from(ctx.entityId, m),
115-
}),
116-
"ARCADE-Guild": (m, ctx) => ({
117-
type: "guild",
118-
identifier: ctx.entityId,
119-
data: GuildModel.from(ctx.entityId, m),
120-
}),
121-
"ARCADE-Member": (m, ctx) => ({
122-
type: "member",
123-
identifier: ctx.entityId,
124-
data: MemberModel.from(ctx.entityId, m),
125-
}),
126-
"ARCADE-Listing": (m, ctx) => ({
127-
type: "listing",
128-
key: ctx.entityId,
129-
data: ListingEvent.from(ctx.entityId, m as any),
130-
}),
131-
"ARCADE-Offer": (m, ctx) => ({
132-
type: "offer",
133-
key: ctx.entityId,
134-
data: OfferEvent.from(ctx.entityId, m as any),
135-
}),
136-
"ARCADE-Sale": (m, ctx) => ({
137-
type: "sale",
138-
key: ctx.entityId,
139-
data: SaleEvent.from(ctx.entityId, m as any),
140-
}),
141-
"ARCADE-TrophyPinning": (m, ctx) => ({
142-
type: "pin",
143-
key: ctx.entityId,
144-
data: PinEvent.from(ctx.entityId, m),
145-
}),
146-
"ARCADE-Follow": (m, ctx) => ({
147-
type: "follow",
148-
key: ctx.entityId,
149-
data: FollowEvent.from(ctx.entityId, m),
150-
}),
151-
},
152-
},
154+
formatters: arcadeFormatters,
153155
},
154156
);
155157

client/src/features/marketplace/token-detail/useTokenDetailViewModel.test.ts

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { describe, it, expect, beforeEach, vi } from "vitest";
22
import { renderHook } from "@testing-library/react";
33
import type { Token } from "@/types/torii";
4-
import type { OrderModel } from "@cartridge/arcade";
4+
import { StatusType, type OrderModel } from "@cartridge/arcade";
55
import { useTokenDetailViewModel } from "./useTokenDetailViewModel";
66

77
const mockUseAccount = vi.fn();
@@ -160,6 +160,7 @@ describe("useTokenDetailViewModel", () => {
160160
tokenId: 1n,
161161
price: "100",
162162
expiration: new Date("2099-01-01T00:00:00").getTime() / 1000,
163+
status: { value: StatusType.Placed },
163164
} as unknown as OrderModel,
164165
],
165166
});
@@ -174,4 +175,56 @@ describe("useTokenDetailViewModel", () => {
174175
expect(result.current.orders).toHaveLength(1);
175176
expect(result.current.isListed).toBe(true);
176177
});
178+
179+
it("should not be listed when all orders are canceled", () => {
180+
mockCollectionOrders.mockReturnValue({
181+
"1": [
182+
{
183+
id: 10,
184+
tokenId: 1n,
185+
price: "100",
186+
status: { value: StatusType.Canceled },
187+
} as unknown as OrderModel,
188+
],
189+
});
190+
191+
const { result } = renderHook(() =>
192+
useTokenDetailViewModel({
193+
collectionAddress: "0xabc",
194+
tokenId: "1",
195+
}),
196+
);
197+
198+
expect(result.current.isListed).toBe(false);
199+
expect(result.current.lowestOrder).toBeNull();
200+
});
201+
202+
it("should select the first active order when mixed with canceled", () => {
203+
const canceled = {
204+
id: 10,
205+
tokenId: 1n,
206+
price: "200",
207+
status: { value: StatusType.Canceled },
208+
} as unknown as OrderModel;
209+
const placed = {
210+
id: 11,
211+
tokenId: 1n,
212+
price: "100",
213+
status: { value: StatusType.Placed },
214+
} as unknown as OrderModel;
215+
216+
mockCollectionOrders.mockReturnValue({
217+
"1": [canceled, placed],
218+
});
219+
220+
const { result } = renderHook(() =>
221+
useTokenDetailViewModel({
222+
collectionAddress: "0xabc",
223+
tokenId: "1",
224+
}),
225+
);
226+
227+
expect(result.current.isListed).toBe(true);
228+
expect(result.current.lowestOrder).toEqual(placed);
229+
});
177230
});

0 commit comments

Comments
 (0)