diff --git a/deploy/migrations/0126_star_gift_user_refs_and_profile_state.down.sql b/deploy/migrations/0126_star_gift_user_refs_and_profile_state.down.sql new file mode 100644 index 00000000..0db8ee07 --- /dev/null +++ b/deploy/migrations/0126_star_gift_user_refs_and_profile_state.down.sql @@ -0,0 +1,6 @@ +-- Durable edit events and repaired user gift projections are intentionally not +-- rewound. Dropping the new lookup/index constraints is sufficient rollback. +ALTER TABLE peer_star_gifts + DROP CONSTRAINT IF EXISTS peer_star_gifts_hidden_unpinned_check; + +DROP TABLE IF EXISTS star_gift_user_message_refs; diff --git a/deploy/migrations/0126_star_gift_user_refs_and_profile_state.up.sql b/deploy/migrations/0126_star_gift_user_refs_and_profile_state.up.sql new file mode 100644 index 00000000..393ece1c --- /dev/null +++ b/deploy/migrations/0126_star_gift_user_refs_and_profile_state.up.sql @@ -0,0 +1,127 @@ +-- Official clients may continue lifecycle actions from a freshly emitted +-- messageActionStarGiftUnique. Keep those user-local message ids as explicit +-- durable references to the same saved gift aggregate. +CREATE TABLE star_gift_user_message_refs ( + owner_user_id bigint NOT NULL REFERENCES users(id) ON DELETE CASCADE, + msg_id integer NOT NULL, + saved_gift_id bigint NOT NULL REFERENCES peer_star_gifts(id) ON DELETE CASCADE, + created_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY (owner_user_id, msg_id), + CONSTRAINT star_gift_user_message_refs_msg_check CHECK (owner_user_id > 0 AND msg_id > 0) +); + +CREATE INDEX star_gift_user_message_refs_saved_idx + ON star_gift_user_message_refs(saved_gift_id, owner_user_id, msg_id); + +INSERT INTO star_gift_user_message_refs(owner_user_id,msg_id,saved_gift_id) +SELECT box.owner_user_id, box.box_id, gift.id +FROM message_boxes box +JOIN unique_star_gifts unique_gift + ON (box.media #>> '{service_action,star_gift_unique,gift,ID}') ~ '^[0-9]+$' + AND unique_gift.id = (box.media #>> '{service_action,star_gift_unique,gift,ID}')::bigint +JOIN peer_star_gifts gift + ON gift.id = unique_gift.source_saved_gift_id + AND gift.owner_peer_type = 'user' + AND gift.owner_peer_id = box.owner_user_id +WHERE NOT box.deleted + AND box.media #>> '{service_action,kind}' = 'star_gift_unique' + AND box.box_id <> gift.msg_id; + +-- Hidden gifts cannot remain pinned. Compacting the whole owner vector here +-- also repairs historical gaps before the invariant is constrained. +ALTER TABLE peer_star_gifts + ADD CONSTRAINT peer_star_gifts_hidden_unpinned_check + CHECK (pinned_order<=6 AND (NOT unsaved OR pinned_order=0)) NOT VALID; + +CREATE TEMP TABLE star_gift_pin_repairs ON COMMIT DROP AS +SELECT id,new_order +FROM ( + SELECT id, + row_number() OVER (PARTITION BY owner_peer_type,owner_peer_id + ORDER BY pinned_order,id)::integer AS new_order + FROM peer_star_gifts + WHERE lifecycle_status='active' AND NOT unsaved AND pinned_order>0 +) ranked +WHERE new_order<=6; + +UPDATE peer_star_gifts SET pinned_order=0 WHERE pinned_order<>0; + +UPDATE peer_star_gifts gift +SET pinned_order=repair.new_order +FROM star_gift_pin_repairs repair +WHERE gift.id=repair.id; + +-- peer and saved_id share one TL flag and are channel-only. Earlier user gift +-- projections set peer=user (and sometimes a user box id in saved_id), which +-- made TDesktop select zero/stale ids instead of the emitted service message. +CREATE TEMP TABLE star_gift_user_unique_media_repairs ( + owner_user_id bigint NOT NULL, + box_id integer NOT NULL, + peer_type text NOT NULL, + peer_id bigint NOT NULL, + repaired_media jsonb NOT NULL, + PRIMARY KEY(owner_user_id,box_id) +) ON COMMIT DROP; + +INSERT INTO star_gift_user_unique_media_repairs(owner_user_id,box_id,peer_type,peer_id,repaired_media) +SELECT box.owner_user_id, + box.box_id, + box.peer_type, + box.peer_id, + jsonb_set( + box.media #- '{service_action,star_gift_unique,saved_id}', + '{service_action,star_gift_unique,peer}', + '{"ID":0,"Type":""}'::jsonb, + true + ) +FROM message_boxes box +WHERE NOT box.deleted + AND box.media #>> '{service_action,kind}' = 'star_gift_unique' + AND box.media #>> '{service_action,star_gift_unique,peer,Type}' = 'user'; + +DO $$ +DECLARE + repair record; + next_pts integer; + event_date integer := EXTRACT(EPOCH FROM clock_timestamp())::integer; +BEGIN + FOR repair IN + SELECT owner_user_id,box_id,peer_type,peer_id,repaired_media + FROM star_gift_user_unique_media_repairs + ORDER BY owner_user_id,box_id + LOOP + INSERT INTO user_update_watermarks(user_id,contiguous_pts) + VALUES(repair.owner_user_id,0) + ON CONFLICT(user_id) DO NOTHING; + + UPDATE user_update_watermarks + SET contiguous_pts=contiguous_pts+1,updated_at=now() + WHERE user_id=repair.owner_user_id + RETURNING contiguous_pts INTO next_pts; + + UPDATE message_boxes + SET media=repair.repaired_media,pts=next_pts + WHERE owner_user_id=repair.owner_user_id AND box_id=repair.box_id AND NOT deleted; + + INSERT INTO user_update_events( + user_id,pts,pts_count,date,event_type,message_box_id,peer_type,peer_id + ) VALUES( + repair.owner_user_id,next_pts,1,event_date,'edit_message',repair.box_id,repair.peer_type,repair.peer_id + ); + + INSERT INTO dispatch_outbox( + target_user_id,pts,event_type,exclude_auth_key_id,exclude_session_id + ) VALUES(repair.owner_user_id,next_pts,'edit_message',0,0); + END LOOP; +END +$$; + +UPDATE private_messages +SET media=jsonb_set( + media #- '{service_action,star_gift_unique,saved_id}', + '{service_action,star_gift_unique,peer}', + '{"ID":0,"Type":""}'::jsonb, + true +) +WHERE media #>> '{service_action,kind}' = 'star_gift_unique' + AND media #>> '{service_action,star_gift_unique,peer,Type}' = 'user'; diff --git a/deploy/migrations/0127_validate_star_gift_profile_state.down.sql b/deploy/migrations/0127_validate_star_gift_profile_state.down.sql new file mode 100644 index 00000000..6c702f48 --- /dev/null +++ b/deploy/migrations/0127_validate_star_gift_profile_state.down.sql @@ -0,0 +1 @@ +-- Validation changes no data and the constraint belongs to migration 0126. diff --git a/deploy/migrations/0127_validate_star_gift_profile_state.up.sql b/deploy/migrations/0127_validate_star_gift_profile_state.up.sql new file mode 100644 index 00000000..dfa9ef6d --- /dev/null +++ b/deploy/migrations/0127_validate_star_gift_profile_state.up.sql @@ -0,0 +1,5 @@ +-- 0126 repaired historical rows and installed the constraint as NOT VALID so +-- it could coexist with the deferrable unique-gift owner trigger in one +-- migration transaction. Validate after that transaction has committed. +ALTER TABLE peer_star_gifts + VALIDATE CONSTRAINT peer_star_gifts_hidden_unpinned_check; diff --git a/deploy/migrations/0128_star_gift_craft_readiness.down.sql b/deploy/migrations/0128_star_gift_craft_readiness.down.sql new file mode 100644 index 00000000..f8007feb --- /dev/null +++ b/deploy/migrations/0128_star_gift_craft_readiness.down.sql @@ -0,0 +1,38 @@ +-- Aggregate/message repairs and emitted edit events are authoritative business +-- history and are intentionally not reversed. Restore only the pre-0128 +-- deferred owner guard shape. +CREATE OR REPLACE FUNCTION public.telesrv_check_unique_star_gift_owner() RETURNS trigger LANGUAGE plpgsql AS $$ +DECLARE + unique_id bigint; + gift_owner_type text; + gift_owner_id bigint; + gift_owner_address text; + gift_burned boolean; + saved_status text; + saved_owner_type text; + saved_owner_id bigint; +BEGIN + IF TG_TABLE_NAME = 'unique_star_gifts' THEN + unique_id := COALESCE(NEW.id, OLD.id); + ELSE + unique_id := COALESCE(NEW.unique_gift_id, OLD.unique_gift_id); + END IF; + IF unique_id IS NULL THEN RETURN NULL; END IF; + SELECT owner_peer_type, owner_peer_id, owner_address, burned + INTO gift_owner_type, gift_owner_id, gift_owner_address, gift_burned + FROM public.unique_star_gifts WHERE id=unique_id; + IF NOT FOUND THEN RETURN NULL; END IF; + SELECT lifecycle_status, owner_peer_type, owner_peer_id + INTO saved_status, saved_owner_type, saved_owner_id + FROM public.peer_star_gifts WHERE unique_gift_id=unique_id; + IF NOT FOUND THEN RAISE EXCEPTION 'unique star gift missing saved aggregate'; END IF; + IF gift_burned THEN + IF saved_status <> 'burned' THEN RAISE EXCEPTION 'burned unique star gift has live saved aggregate'; END IF; + ELSIF gift_owner_address <> '' THEN + IF saved_status <> 'exported' THEN RAISE EXCEPTION 'exported unique star gift has non-exported saved aggregate'; END IF; + ELSIF saved_status <> 'active' OR gift_owner_type IS DISTINCT FROM saved_owner_type OR gift_owner_id IS DISTINCT FROM saved_owner_id THEN + RAISE EXCEPTION 'unique star gift owner mismatch'; + END IF; + RETURN NULL; +END; +$$; diff --git a/deploy/migrations/0128_star_gift_craft_readiness.up.sql b/deploy/migrations/0128_star_gift_craft_readiness.up.sql new file mode 100644 index 00000000..19af1d49 --- /dev/null +++ b/deploy/migrations/0128_star_gift_craft_readiness.up.sql @@ -0,0 +1,498 @@ +-- Official Android clients use a positive can_craft_at both as the Craft +-- capability marker and as the readiness boundary. Earlier zero-delay +-- upgrades persisted 0 while retaining a positive craft chance, so TDesktop +-- could Craft the gift but Android hid the entry entirely. +-- +-- Block concurrent lifecycle/message writers while aggregate facts, message +-- snapshots and durable edit edges are repaired in this migration transaction. +LOCK TABLE public.peer_star_gifts, public.unique_star_gifts, + public.message_boxes, public.private_messages IN SHARE ROW EXCLUSIVE MODE; + +DO $$ +BEGIN + -- Craft capability remains an intrinsic collectible fact while ownership + -- moves between users and channels. Terminal/external states cannot Craft. + UPDATE public.unique_star_gifts unique_gift + SET craft_chance_permille = 0, + updated_at = now() + FROM public.peer_star_gifts saved_gift + WHERE saved_gift.unique_gift_id = unique_gift.id + AND unique_gift.craft_chance_permille > 0 + AND ( + saved_gift.lifecycle_status <> 'active' + OR unique_gift.owner_address <> '' + OR unique_gift.burned + OR unique_gift.crafted + ); + + UPDATE public.peer_star_gifts saved_gift + SET can_craft_at = 0 + FROM public.unique_star_gifts unique_gift + WHERE unique_gift.id = saved_gift.unique_gift_id + AND unique_gift.craft_chance_permille = 0 + AND saved_gift.can_craft_at <> 0; + + IF EXISTS ( + SELECT 1 + FROM public.unique_star_gifts unique_gift + JOIN public.peer_star_gifts saved_gift + ON saved_gift.unique_gift_id = unique_gift.id + WHERE unique_gift.craft_chance_permille > 0 + AND ( + saved_gift.owner_peer_type NOT IN ('user', 'channel') + OR saved_gift.lifecycle_status <> 'active' + OR unique_gift.owner_address <> '' + OR unique_gift.burned + OR unique_gift.crafted + OR NOT EXISTS ( + SELECT 1 + FROM public.star_gift_collectible_models model + WHERE model.collectible_revision_id = unique_gift.collectible_revision_id + AND model.crafted + ) + ) + ) THEN + RAISE EXCEPTION 'positive star gift craft chance has no valid owned aggregate'; + END IF; + + -- created_at is the stable persisted proxy for the original upgrade + -- transaction date on legacy rows. New writes use the exact request date. + UPDATE public.peer_star_gifts saved_gift + SET can_craft_at = GREATEST( + 1, + LEAST(2147483647, FLOOR(EXTRACT(EPOCH FROM unique_gift.created_at))::bigint)::integer + ) + FROM public.unique_star_gifts unique_gift + WHERE unique_gift.id = saved_gift.unique_gift_id + AND unique_gift.craft_chance_permille > 0 + AND saved_gift.can_craft_at = 0; + + IF EXISTS ( + SELECT 1 + FROM public.peer_star_gifts saved_gift + JOIN public.unique_star_gifts unique_gift + ON unique_gift.id = saved_gift.unique_gift_id + WHERE (unique_gift.craft_chance_permille > 0) + IS DISTINCT FROM (saved_gift.can_craft_at > 0) + ) THEN + RAISE EXCEPTION 'star gift craft chance/readiness repair did not converge'; + END IF; +END +$$; + +CREATE TEMP TABLE star_gift_craft_message_repairs ( + owner_user_id bigint NOT NULL, + box_id integer NOT NULL, + unique_gift_id bigint NOT NULL, + desired_craft_chance integer NOT NULL, + desired_can_craft_at integer NOT NULL, + PRIMARY KEY (owner_user_id, box_id) +) ON COMMIT DROP; + +-- Adding capability is owner-scoped: repair only the current owner's +-- authoritative unique action (upgrade_msg_id) and the other visible box of +-- that same logical private message. Never add Craft back to an old owner's +-- historical transfer/resale action. +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 + FROM public.peer_star_gifts saved_gift + JOIN public.unique_star_gifts unique_gift + ON unique_gift.id = saved_gift.unique_gift_id + WHERE saved_gift.owner_peer_type = 'user' + AND saved_gift.lifecycle_status = 'active' + AND saved_gift.can_craft_at > 0 + AND NOT EXISTS ( + SELECT 1 + FROM public.message_boxes owner_box + WHERE owner_box.owner_user_id = saved_gift.owner_peer_id + AND owner_box.box_id = saved_gift.upgrade_msg_id + AND NOT owner_box.deleted + AND owner_box.media #>> '{service_action,kind}' = 'star_gift_unique' + AND owner_box.media #>> '{service_action,star_gift_unique,gift,ID}' = unique_gift.id::text + ) + ) THEN + RAISE EXCEPTION 'craftable star gift is missing its current owner action'; + END IF; + + IF EXISTS ( + SELECT 1 + FROM public.message_boxes box + WHERE box.media #>> '{service_action,kind}' = 'star_gift_unique' + AND box.media #> '{service_action,star_gift_unique,can_craft_at}' IS NOT NULL + AND ( + jsonb_typeof(box.media #> '{service_action,star_gift_unique,can_craft_at}') <> 'number' + OR COALESCE(box.media #>> '{service_action,star_gift_unique,can_craft_at}', '') !~ '^[0-9]+$' + OR (box.media #>> '{service_action,star_gift_unique,can_craft_at}')::numeric > 2147483647 + ) + ) THEN + RAISE EXCEPTION 'star gift message has malformed can_craft_at'; + END IF; + + IF EXISTS ( + SELECT 1 + FROM public.message_boxes box + WHERE box.media #>> '{service_action,kind}' = 'star_gift_unique' + AND box.media #> '{service_action,star_gift_unique,gift,CraftChancePermille}' IS NOT NULL + AND ( + jsonb_typeof(box.media #> '{service_action,star_gift_unique,gift,CraftChancePermille}') <> 'number' + OR COALESCE(box.media #>> '{service_action,star_gift_unique,gift,CraftChancePermille}', '') !~ '^[0-9]+$' + OR (box.media #>> '{service_action,star_gift_unique,gift,CraftChancePermille}')::numeric > 1000 + ) + ) THEN + RAISE EXCEPTION 'star gift message has malformed craft chance'; + END IF; +END +$$; + +INSERT INTO star_gift_craft_message_repairs( + owner_user_id, box_id, unique_gift_id, + desired_craft_chance, desired_can_craft_at +) +SELECT visible_box.owner_user_id, + visible_box.box_id, + unique_gift.id, + unique_gift.craft_chance_permille, + saved_gift.can_craft_at +FROM public.peer_star_gifts saved_gift +JOIN public.unique_star_gifts unique_gift + ON unique_gift.id = saved_gift.unique_gift_id +JOIN public.message_boxes owner_box + ON owner_box.owner_user_id = saved_gift.owner_peer_id + AND owner_box.box_id = saved_gift.upgrade_msg_id + AND NOT owner_box.deleted + AND owner_box.media #>> '{service_action,star_gift_unique,gift,ID}' = unique_gift.id::text +JOIN public.message_boxes visible_box + ON visible_box.message_sender_id = owner_box.message_sender_id + AND visible_box.private_message_id = owner_box.private_message_id + AND NOT visible_box.deleted +WHERE saved_gift.owner_peer_type = 'user' + AND saved_gift.lifecycle_status = 'active' + AND saved_gift.can_craft_at > 0 + AND ( + COALESCE(NULLIF(visible_box.media #>> '{service_action,star_gift_unique,can_craft_at}', '')::integer, 0) + IS DISTINCT FROM saved_gift.can_craft_at + OR COALESCE(NULLIF(visible_box.media #>> '{service_action,star_gift_unique,gift,CraftChancePermille}', '')::integer, 0) + IS DISTINCT FROM unique_gift.craft_chance_permille + ); + +-- Only the current owner's authoritative logical message may expose Craft. +-- Terminal gifts, channel-owned gifts (until channel Craft is implemented), +-- and old-owner historical actions must have both wire markers removed. +INSERT INTO star_gift_craft_message_repairs( + owner_user_id, box_id, unique_gift_id, + desired_craft_chance, desired_can_craft_at +) +SELECT box.owner_user_id, box.box_id, unique_gift.id, 0, 0 +FROM public.message_boxes box +JOIN public.unique_star_gifts unique_gift + ON (box.media #>> '{service_action,star_gift_unique,gift,ID}') ~ '^[0-9]+$' + AND unique_gift.id = (box.media #>> '{service_action,star_gift_unique,gift,ID}')::bigint +JOIN public.peer_star_gifts saved_gift + ON saved_gift.unique_gift_id = unique_gift.id +WHERE NOT box.deleted + AND box.media #>> '{service_action,kind}' = 'star_gift_unique' + AND ( + box.media #> '{service_action,star_gift_unique,can_craft_at}' IS NOT NULL + OR box.media #> '{service_action,star_gift_unique,gift,CraftChancePermille}' IS NOT NULL + ) + AND NOT EXISTS ( + SELECT 1 + FROM public.message_boxes authority + WHERE saved_gift.owner_peer_type = 'user' + AND saved_gift.lifecycle_status = 'active' + AND saved_gift.can_craft_at > 0 + AND unique_gift.craft_chance_permille > 0 + AND authority.owner_user_id = saved_gift.owner_peer_id + AND authority.box_id = saved_gift.upgrade_msg_id + AND NOT authority.deleted + AND authority.media #>> '{service_action,kind}' = 'star_gift_unique' + AND authority.media #>> '{service_action,star_gift_unique,gift,ID}' = unique_gift.id::text + AND authority.message_sender_id = box.message_sender_id + AND authority.private_message_id = box.private_message_id + ) +ON CONFLICT (owner_user_id, box_id) DO UPDATE +SET unique_gift_id = EXCLUDED.unique_gift_id, + desired_craft_chance = 0, + desired_can_craft_at = 0; + +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 + FROM star_gift_craft_message_repairs target + JOIN public.message_boxes box + ON box.owner_user_id = target.owner_user_id + AND box.box_id = target.box_id + WHERE box.deleted + OR box.media #>> '{service_action,kind}' <> 'star_gift_unique' + OR box.media #>> '{service_action,star_gift_unique,gift,ID}' <> target.unique_gift_id::text + ) THEN + RAISE EXCEPTION 'craft readiness repair target is not the expected unique gift action'; + END IF; + + IF EXISTS ( + SELECT 1 + FROM star_gift_craft_message_repairs target + JOIN public.message_boxes box + ON box.owner_user_id = target.owner_user_id + AND box.box_id = target.box_id + WHERE NOT EXISTS ( + SELECT 1 + FROM public.private_messages private_message + WHERE private_message.sender_user_id = box.message_sender_id + AND private_message.id = box.private_message_id + AND private_message.media #>> '{service_action,kind}' = 'star_gift_unique' + AND private_message.media #>> '{service_action,star_gift_unique,gift,ID}' = target.unique_gift_id::text + ) + ) THEN + RAISE EXCEPTION 'craft readiness repair target has no matching private message'; + END IF; + + IF EXISTS ( + SELECT 1 + FROM star_gift_craft_message_repairs target + JOIN public.message_boxes box + ON box.owner_user_id = target.owner_user_id + AND box.box_id = target.box_id + GROUP BY box.message_sender_id, box.private_message_id + HAVING COUNT(DISTINCT ( + target.unique_gift_id, + target.desired_craft_chance, + target.desired_can_craft_at + )) <> 1 + ) THEN + RAISE EXCEPTION 'craft readiness repair has conflicting logical message targets'; + END IF; +END +$$; + +DO $$ +DECLARE + repair record; + next_pts integer; + event_date integer := LEAST(2147483647, EXTRACT(EPOCH FROM clock_timestamp())::bigint)::integer; + repaired_media jsonb; + repaired_private_media jsonb; + affected_rows bigint; +BEGIN + FOR repair IN + SELECT target.owner_user_id, + target.box_id, + target.unique_gift_id, + target.desired_craft_chance, + target.desired_can_craft_at, + box.peer_type, + box.peer_id, + box.message_sender_id, + box.private_message_id, + box.media + FROM star_gift_craft_message_repairs target + JOIN public.message_boxes box + ON box.owner_user_id = target.owner_user_id + AND box.box_id = target.box_id + AND NOT box.deleted + ORDER BY target.owner_user_id, target.box_id + FOR UPDATE OF box + LOOP + IF repair.desired_craft_chance > 0 THEN + repaired_media := jsonb_set( + jsonb_set( + repair.media, + '{service_action,star_gift_unique,gift,CraftChancePermille}', + to_jsonb(repair.desired_craft_chance), + true + ), + '{service_action,star_gift_unique,can_craft_at}', + to_jsonb(repair.desired_can_craft_at), + true + ); + ELSE + repaired_media := repair.media + #- '{service_action,star_gift_unique,can_craft_at}' + #- '{service_action,star_gift_unique,gift,CraftChancePermille}'; + END IF; + + IF repaired_media #>> '{service_action,kind}' <> 'star_gift_unique' + OR repaired_media #>> '{service_action,star_gift_unique,gift,ID}' <> repair.unique_gift_id::text + OR ( + repair.desired_craft_chance > 0 + AND ( + repaired_media #>> '{service_action,star_gift_unique,gift,CraftChancePermille}' + IS DISTINCT FROM repair.desired_craft_chance::text + OR repaired_media #>> '{service_action,star_gift_unique,can_craft_at}' + IS DISTINCT FROM repair.desired_can_craft_at::text + ) + ) + OR ( + repair.desired_craft_chance = 0 + AND ( + repaired_media #> '{service_action,star_gift_unique,gift,CraftChancePermille}' IS NOT NULL + OR repaired_media #> '{service_action,star_gift_unique,can_craft_at}' IS NOT NULL + ) + ) THEN + RAISE EXCEPTION 'craft readiness repair cannot project message box for user %, box %', + repair.owner_user_id, repair.box_id; + END IF; + + INSERT INTO public.user_update_watermarks (user_id, contiguous_pts) + VALUES (repair.owner_user_id, 0) + ON CONFLICT (user_id) DO NOTHING; + + UPDATE public.user_update_watermarks + SET contiguous_pts = contiguous_pts + 1, + updated_at = now() + WHERE user_id = repair.owner_user_id + RETURNING contiguous_pts INTO next_pts; + + UPDATE public.message_boxes + SET media = repaired_media, + pts = next_pts + WHERE owner_user_id = repair.owner_user_id + AND box_id = repair.box_id + AND NOT deleted; + GET DIAGNOSTICS affected_rows = ROW_COUNT; + IF affected_rows <> 1 THEN + RAISE EXCEPTION 'craft readiness repair lost user %, box %', repair.owner_user_id, repair.box_id; + END IF; + + SELECT media + INTO repaired_private_media + FROM public.private_messages + WHERE sender_user_id = repair.message_sender_id + AND id = repair.private_message_id + FOR UPDATE; + IF NOT FOUND THEN + RAISE EXCEPTION 'craft readiness repair missing private message for user %, box %', + repair.owner_user_id, repair.box_id; + END IF; + + IF repaired_private_media #>> '{service_action,kind}' <> 'star_gift_unique' + OR repaired_private_media #>> '{service_action,star_gift_unique,gift,ID}' <> repair.unique_gift_id::text THEN + RAISE EXCEPTION 'craft readiness repair found mismatched private message for user %, box %', + repair.owner_user_id, repair.box_id; + END IF; + + IF repair.desired_craft_chance > 0 THEN + repaired_private_media := jsonb_set( + jsonb_set( + repaired_private_media, + '{service_action,star_gift_unique,gift,CraftChancePermille}', + to_jsonb(repair.desired_craft_chance), + true + ), + '{service_action,star_gift_unique,can_craft_at}', + to_jsonb(repair.desired_can_craft_at), + true + ); + IF repaired_private_media #>> '{service_action,star_gift_unique,can_craft_at}' + IS DISTINCT FROM repair.desired_can_craft_at::text + OR repaired_private_media #>> '{service_action,star_gift_unique,gift,CraftChancePermille}' + IS DISTINCT FROM repair.desired_craft_chance::text THEN + RAISE EXCEPTION 'craft readiness repair cannot project private message for user %, box %', + repair.owner_user_id, repair.box_id; + END IF; + ELSE + repaired_private_media := repaired_private_media + #- '{service_action,star_gift_unique,can_craft_at}' + #- '{service_action,star_gift_unique,gift,CraftChancePermille}'; + IF repaired_private_media #> '{service_action,star_gift_unique,can_craft_at}' IS NOT NULL + OR repaired_private_media #> '{service_action,star_gift_unique,gift,CraftChancePermille}' IS NOT NULL THEN + RAISE EXCEPTION 'craft readiness repair cannot clear private message for user %, box %', + repair.owner_user_id, repair.box_id; + END IF; + END IF; + + UPDATE public.private_messages + SET media = repaired_private_media + WHERE sender_user_id = repair.message_sender_id + AND id = repair.private_message_id; + GET DIAGNOSTICS affected_rows = ROW_COUNT; + IF affected_rows <> 1 THEN + RAISE EXCEPTION 'craft readiness repair lost private message for user %, box %', + repair.owner_user_id, repair.box_id; + END IF; + + INSERT INTO public.user_update_events ( + user_id, pts, pts_count, date, event_type, + message_box_id, peer_type, peer_id + ) VALUES ( + repair.owner_user_id, next_pts, 1, event_date, 'edit_message', + repair.box_id, repair.peer_type, repair.peer_id + ); + + INSERT INTO public.dispatch_outbox ( + target_user_id, pts, event_type, + exclude_auth_key_id, exclude_session_id + ) VALUES ( + repair.owner_user_id, next_pts, 'edit_message', 0, 0 + ); + END LOOP; +END +$$; + +-- Extend the existing deferred unique/saved aggregate guard. Upgrade, Craft +-- and export update the two tables in separate statements, so commit-time +-- validation observes the final atomic state without a read fallback. +CREATE OR REPLACE FUNCTION public.telesrv_check_unique_star_gift_owner() RETURNS trigger LANGUAGE plpgsql AS $$ +DECLARE + unique_id bigint; + gift_owner_type text; + gift_owner_id bigint; + gift_owner_address text; + gift_burned boolean; + gift_crafted boolean; + gift_craft_chance integer; + gift_revision_id bigint; + saved_status text; + saved_owner_type text; + saved_owner_id bigint; + saved_can_craft_at integer; +BEGIN + IF TG_TABLE_NAME = 'unique_star_gifts' THEN + unique_id := COALESCE(NEW.id, OLD.id); + ELSE + unique_id := COALESCE(NEW.unique_gift_id, OLD.unique_gift_id); + END IF; + IF unique_id IS NULL THEN RETURN NULL; END IF; + SELECT owner_peer_type, owner_peer_id, owner_address, burned, crafted, + craft_chance_permille, collectible_revision_id + INTO gift_owner_type, gift_owner_id, gift_owner_address, gift_burned, gift_crafted, + gift_craft_chance, gift_revision_id + FROM public.unique_star_gifts WHERE id=unique_id; + IF NOT FOUND THEN RETURN NULL; END IF; + SELECT lifecycle_status, owner_peer_type, owner_peer_id, can_craft_at + INTO saved_status, saved_owner_type, saved_owner_id, saved_can_craft_at + FROM public.peer_star_gifts WHERE unique_gift_id=unique_id; + IF NOT FOUND THEN RAISE EXCEPTION 'unique star gift missing saved aggregate'; END IF; + IF gift_burned THEN + IF saved_status <> 'burned' THEN RAISE EXCEPTION 'burned unique star gift has live saved aggregate'; END IF; + ELSIF gift_owner_address <> '' THEN + IF saved_status <> 'exported' THEN RAISE EXCEPTION 'exported unique star gift has non-exported saved aggregate'; END IF; + ELSIF saved_status <> 'active' OR gift_owner_type IS DISTINCT FROM saved_owner_type OR gift_owner_id IS DISTINCT FROM saved_owner_id THEN + RAISE EXCEPTION 'unique star gift owner mismatch'; + END IF; + IF gift_craft_chance > 0 THEN + IF saved_can_craft_at <= 0 + OR saved_status <> 'active' + OR saved_owner_type NOT IN ('user', 'channel') + OR gift_owner_address <> '' + OR gift_burned + OR gift_crafted THEN + RAISE EXCEPTION 'unique star gift craft capability has invalid aggregate state'; + END IF; + IF NOT EXISTS ( + SELECT 1 + FROM public.star_gift_collectible_models model + WHERE model.collectible_revision_id = gift_revision_id + AND model.crafted + ) THEN + RAISE EXCEPTION 'unique star gift craft chance has no crafted model'; + END IF; + ELSIF saved_can_craft_at <> 0 THEN + RAISE EXCEPTION 'unique star gift readiness exists without craft chance'; + END IF; + RETURN NULL; +END; +$$; diff --git a/deploy/migrations/0129_star_gift_craft_output_receipt.down.sql b/deploy/migrations/0129_star_gift_craft_output_receipt.down.sql new file mode 100644 index 00000000..62e0f108 --- /dev/null +++ b/deploy/migrations/0129_star_gift_craft_output_receipt.down.sql @@ -0,0 +1,4 @@ +ALTER TABLE public.star_gift_craft_commands + DROP CONSTRAINT IF EXISTS star_gift_craft_output_receipt_check, + DROP COLUMN IF EXISTS output_fingerprint, + DROP COLUMN IF EXISTS output_media; diff --git a/deploy/migrations/0129_star_gift_craft_output_receipt.up.sql b/deploy/migrations/0129_star_gift_craft_output_receipt.up.sql new file mode 100644 index 00000000..5fd8c420 --- /dev/null +++ b/deploy/migrations/0129_star_gift_craft_output_receipt.up.sql @@ -0,0 +1,65 @@ +-- A successful Craft outcome and its self-service message are separated by a +-- process boundary. Freeze the exact output intent in the outcome receipt so +-- retries never rebuild a different message from mutable gift/profile state. +ALTER TABLE public.star_gift_craft_commands + ADD COLUMN output_media jsonb, + ADD COLUMN output_fingerprint bytea; + +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 + FROM public.star_gift_craft_commands command + WHERE command.success + AND 1 <> ( + SELECT COUNT(*) + FROM public.private_messages message + WHERE message.sender_user_id = command.user_id + AND message.recipient_user_id = command.user_id + AND message.sender_snapshot #>> '{message,Media,service_action,kind}' = 'star_gift_unique' + AND message.sender_snapshot #>> '{message,Media,service_action,star_gift_unique,gift,ID}' = command.result_unique_gift_id::text + AND COALESCE((message.sender_snapshot #>> '{message,Media,service_action,star_gift_unique,craft}')::boolean, false) + AND octet_length(message.request_fingerprint) = 32 + ) + ) THEN + RAISE EXCEPTION 'successful craft command is missing its exact immutable output receipt'; + END IF; +END +$$; + +WITH outputs AS ( + SELECT command.user_id, + command.command_key, + message.sender_snapshot #> '{message,Media}' AS media, + message.request_fingerprint + FROM public.star_gift_craft_commands command + JOIN public.private_messages message + ON message.sender_user_id = command.user_id + AND message.recipient_user_id = command.user_id + AND message.sender_snapshot #>> '{message,Media,service_action,kind}' = 'star_gift_unique' + AND message.sender_snapshot #>> '{message,Media,service_action,star_gift_unique,gift,ID}' = command.result_unique_gift_id::text + AND COALESCE((message.sender_snapshot #>> '{message,Media,service_action,star_gift_unique,craft}')::boolean, false) + WHERE command.success +) +UPDATE public.star_gift_craft_commands command +SET output_media = output.media, + output_fingerprint = output.request_fingerprint +FROM outputs output +WHERE command.user_id = output.user_id + AND command.command_key = output.command_key; + +ALTER TABLE public.star_gift_craft_commands + ADD CONSTRAINT star_gift_craft_output_receipt_check CHECK ( + (success + AND result_unique_gift_id IS NOT NULL + AND output_media IS NOT NULL + AND output_media #>> '{service_action,kind}' = 'star_gift_unique' + AND COALESCE((output_media #>> '{service_action,star_gift_unique,craft}')::boolean, false) + AND output_media #>> '{service_action,star_gift_unique,gift,ID}' = result_unique_gift_id::text + AND octet_length(output_fingerprint) = 32) + OR + (NOT success + AND result_unique_gift_id IS NULL + AND output_media IS NULL + AND output_fingerprint IS NULL) + ); diff --git a/internal/app/help/service.go b/internal/app/help/service.go index 09b2c3e2..b2d64492 100644 --- a/internal/app/help/service.go +++ b/internal/app/help/service.go @@ -55,9 +55,9 @@ const tdesktopClient = "tdesktop" // // WebK directly calls Array.some on fragment_prefixes while rendering user profiles, // so this compatibility key must always remain an array, even when it is empty. -const tdesktopDefaultAppConfigBase = `{"chat_read_mark_expire_period":604800,"chat_read_mark_size_threshold":50,"pm_read_date_expire_period":604800,"quote_length_max":1024,"telegram_antispam_group_size_min":200,"telegram_antispam_user_id":"5434988373","fragment_prefixes":["888"],"forum_upgrade_participants_min":2,"reactions_default":{"_":"reactionEmoji","emoticon":"👍"},"reactions_uniq_max":11,"reactions_user_max_default":1,"reactions_user_max_premium":3,"reactions_in_chat_max":3,"boosts_channel_level_max":100,"rich_message_posting":"enabled","upload_markup_video":true,"emojies_send_dice":["🎲","🎯","🏀","⚽","⚽️","🎳","🎰"],"premium_purchase_blocked":false,"stars_purchase_blocked":false,"stargifts_blocked":false,"stories_stealth_future_period":1500,"stories_stealth_past_period":300,"stories_stealth_cooldown_period":10800,"quick_replies_limit":100,"quick_reply_messages_limit":20,"business_chat_links_limit":100,"dialog_filters_enabled":true,"chatlist_update_period":3600,"chatlist_invites_limit_default":3,"chatlist_invites_limit_premium":20,"chatlists_joined_limit_default":2,"chatlists_joined_limit_premium":20,"about_length_limit_default":70,"about_length_limit_premium":140,"caption_length_limit_default":1024,"caption_length_limit_premium":4096,"channels_limit_default":500,"channels_limit_premium":1000,"channels_public_limit_default":10,"channels_public_limit_premium":20,"dialog_filters_limit_default":10,"dialog_filters_limit_premium":20,"dialog_filters_chats_limit_default":100,"dialog_filters_chats_limit_premium":200,"dialogs_pinned_limit_default":5,"dialogs_pinned_limit_premium":10,"dialogs_folder_pinned_limit_default":100,"dialogs_folder_pinned_limit_premium":200,"saved_dialogs_pinned_limit_default":5,"saved_dialogs_pinned_limit_premium":100,"saved_gifs_limit_default":200,"saved_gifs_limit_premium":400,"stickers_faved_limit_default":5,"stickers_faved_limit_premium":10,"recommended_channels_limit_default":10,"recommended_channels_limit_premium":100,"aicompose_tone_examples_num":3,"aicompose_tone_title_length_max":12,"aicompose_tone_prompt_length_max":1024,"aicompose_tone_saved_limit_default":5,"aicompose_tone_saved_limit_premium":20,"upload_max_fileparts_default":4000,"upload_max_fileparts_premium":8000` +const tdesktopDefaultAppConfigBase = `{"chat_read_mark_expire_period":604800,"chat_read_mark_size_threshold":50,"pm_read_date_expire_period":604800,"quote_length_max":1024,"telegram_antispam_group_size_min":200,"telegram_antispam_user_id":"5434988373","fragment_prefixes":["888"],"forum_upgrade_participants_min":2,"reactions_default":{"_":"reactionEmoji","emoticon":"👍"},"reactions_uniq_max":11,"reactions_user_max_default":1,"reactions_user_max_premium":3,"reactions_in_chat_max":3,"boosts_channel_level_max":100,"rich_message_posting":"enabled","upload_markup_video":true,"emojies_send_dice":["🎲","🎯","🏀","⚽","⚽️","🎳","🎰"],"premium_purchase_blocked":false,"stars_purchase_blocked":false,"stargifts_blocked":false,"stargifts_pinned_to_top_limit":6,"stories_stealth_future_period":1500,"stories_stealth_past_period":300,"stories_stealth_cooldown_period":10800,"quick_replies_limit":100,"quick_reply_messages_limit":20,"business_chat_links_limit":100,"dialog_filters_enabled":true,"chatlist_update_period":3600,"chatlist_invites_limit_default":3,"chatlist_invites_limit_premium":20,"chatlists_joined_limit_default":2,"chatlists_joined_limit_premium":20,"about_length_limit_default":70,"about_length_limit_premium":140,"caption_length_limit_default":1024,"caption_length_limit_premium":4096,"channels_limit_default":500,"channels_limit_premium":1000,"channels_public_limit_default":10,"channels_public_limit_premium":20,"dialog_filters_limit_default":10,"dialog_filters_limit_premium":20,"dialog_filters_chats_limit_default":100,"dialog_filters_chats_limit_premium":200,"dialogs_pinned_limit_default":5,"dialogs_pinned_limit_premium":10,"dialogs_folder_pinned_limit_default":100,"dialogs_folder_pinned_limit_premium":200,"saved_dialogs_pinned_limit_default":5,"saved_dialogs_pinned_limit_premium":100,"saved_gifs_limit_default":200,"saved_gifs_limit_premium":400,"stickers_faved_limit_default":5,"stickers_faved_limit_premium":10,"recommended_channels_limit_default":10,"recommended_channels_limit_premium":100,"aicompose_tone_examples_num":3,"aicompose_tone_title_length_max":12,"aicompose_tone_prompt_length_max":1024,"aicompose_tone_saved_limit_default":5,"aicompose_tone_saved_limit_premium":20,"upload_max_fileparts_default":4000,"upload_max_fileparts_premium":8000` -const defaultAppConfigHash = 23 // 默认 app config 内容变更时必须递增,否则缓存端只会收到 notModified。 +const defaultAppConfigHash = 24 // 默认 app config 内容变更时必须递增,否则缓存端只会收到 notModified。 // Service 提供客户端启动配置与国家区号目录。 // diff --git a/internal/app/help/service_premium_test.go b/internal/app/help/service_premium_test.go index fc19fa37..3f82287a 100644 --- a/internal/app/help/service_premium_test.go +++ b/internal/app/help/service_premium_test.go @@ -48,6 +48,7 @@ func TestAppConfigPremiumKeys(t *testing.T) { "reactions_user_max_default": 1, "reactions_user_max_premium": 3, "boosts_channel_level_max": 100, + "stargifts_pinned_to_top_limit": 6, "about_length_limit_default": 70, "about_length_limit_premium": 140, "dialogs_pinned_limit_default": 5, diff --git a/internal/domain/star_gift.go b/internal/domain/star_gift.go index 21ea447b..5a8a54f1 100644 --- a/internal/domain/star_gift.go +++ b/internal/domain/star_gift.go @@ -879,6 +879,9 @@ const ( MaxStarGiftCollectionTitleRunes = 12 MaxStarGiftCollectionsPerPeer = 100 MaxStarGiftCollectionItems = 1000 + // MaxPinnedStarGifts matches stargifts_pinned_to_top_limit advertised to + // official clients. Pin requests are complete replacement vectors. + MaxPinnedStarGifts = 6 ) // Star gift 哨兵错误(rpc 层 errors.Is 映射为 tgerr)。 diff --git a/internal/rpc/convert_messages.go b/internal/rpc/convert_messages.go index 8715cb1b..96f88f54 100644 --- a/internal/rpc/convert_messages.go +++ b/internal/rpc/convert_messages.go @@ -269,7 +269,10 @@ func tgMessageActionStarGiftUnique(action *domain.MessageStarGiftUniqueAction) t if action.DropOriginalDetailsStars > 0 { out.SetDropOriginalDetailsStars(action.DropOriginalDetailsStars) } - if action.CanCraftAt > 0 { + // Channel Craft is not executable yet. Gate on the authoritative gift owner + // as a final wire boundary so historical JSON/admin-log actions or a future + // constructor cannot accidentally expose Android's Craft entry marker. + if action.Gift.Owner.Type == domain.PeerTypeUser && action.CanCraftAt > 0 { out.SetCanCraftAt(action.CanCraftAt) } if action.FromUserID != 0 { diff --git a/internal/rpc/payments_star_gift_lifecycle.go b/internal/rpc/payments_star_gift_lifecycle.go index 904eae74..aee0df20 100644 --- a/internal/rpc/payments_star_gift_lifecycle.go +++ b/internal/rpc/payments_star_gift_lifecycle.go @@ -1007,9 +1007,11 @@ func starGiftLifecycleErr(err error) error { return tgerr.New(400, "STARGIFT_OWNER_INVALID") case errors.Is(err, domain.ErrStarGiftWithdrawalUnavailable): return tgerr.New(400, "STARGIFT_WITHDRAWAL_UNAVAILABLE") + case errors.Is(err, domain.ErrStarGiftCraftUnavailable): + return tgerr.New(400, "STARGIFT_CRAFT_UNAVAILABLE") case errors.Is(err, domain.ErrStarGiftNotFound), errors.Is(err, domain.ErrStarGiftResaleUnavailable), errors.Is(err, domain.ErrStarGiftTransferUnavailable), errors.Is(err, domain.ErrStarGiftOfferInvalid), - errors.Is(err, domain.ErrStarGiftCraftUnavailable), errors.Is(err, domain.ErrStarGiftAuctionUnavailable), + errors.Is(err, domain.ErrStarGiftAuctionUnavailable), errors.Is(err, domain.ErrStarGiftUnavailable), errors.Is(err, domain.ErrStarGiftInvalid), errors.Is(err, domain.ErrStarGiftCollectibleUnavailable): return starGiftInvalidErr() diff --git a/internal/rpc/payments_star_gifts.go b/internal/rpc/payments_star_gifts.go index d74864cf..ee63ace1 100644 --- a/internal/rpc/payments_star_gifts.go +++ b/internal/rpc/payments_star_gifts.go @@ -1071,6 +1071,27 @@ func tgSavedStarGifts(gifts []domain.SavedStarGift, catalog map[int64]domain.Sta item.SetPrepaidUpgradeHash(g.PrepaidUpgradeHash) } } + if g.CanExportAt > 0 { + item.SetCanExportAt(g.CanExportAt) + } + if g.TransferStars > 0 { + item.SetTransferStars(g.TransferStars) + } + if g.CanTransferAt > 0 { + item.SetCanTransferAt(g.CanTransferAt) + } + if g.CanResellAt > 0 { + item.SetCanResellAt(g.CanResellAt) + } + if g.DropOriginalDetailsStars > 0 { + item.SetDropOriginalDetailsStars(g.DropOriginalDetailsStars) + } + // Channel Craft execution is not implemented yet. Android uses this field + // as the entry/capability marker, so only advertise the currently + // executable user-owned path while retaining the durable DB entitlement. + if g.Owner.Type == domain.PeerTypeUser && g.CanCraftAt > 0 { + item.SetCanCraftAt(g.CanCraftAt) + } if g.PinnedOrder > 0 { item.PinnedToTop = true } diff --git a/internal/rpc/payments_star_gifts_rpc_test.go b/internal/rpc/payments_star_gifts_rpc_test.go index 7d7da827..8e615e9e 100644 --- a/internal/rpc/payments_star_gifts_rpc_test.go +++ b/internal/rpc/payments_star_gifts_rpc_test.go @@ -118,7 +118,7 @@ func (s *craftStarGiftRPCService) GetSaved(_ context.Context, ref domain.SavedSt } continue } - if saved.MsgID == ref.MsgID { + if saved.MsgID == ref.MsgID || saved.UpgradeMsgID == ref.MsgID { return saved, true, nil } } @@ -181,11 +181,12 @@ func TestCraftStarGiftAcceptsOfficialSlugAndCanonicalizesAliases(t *testing.T) { t.Fatalf("duplicate aliases err=%v craft calls=%d", err, service.craftCall) } - _, err = r.onPaymentsCraftStarGift(ctx, &tg.PaymentsCraftStarGiftRequest{Stargift: []tg.InputSavedStarGiftClass{ + updates, err = r.onPaymentsCraftStarGift(ctx, &tg.PaymentsCraftStarGiftRequest{Stargift: []tg.InputSavedStarGiftClass{ &tg.InputSavedStarGiftUser{MsgID: 116}, }}) - if !tgerr.Is(err, "STARGIFT_INVALID") || service.craftCall != 0 { - t.Fatalf("upgrade message id accepted as gift identity: err=%v craft calls=%d", err, service.craftCall) + if err != nil || updates == nil || service.craftCall != 1 || service.craftReq.CommandKey != "rpc:50" || + len(service.craftReq.Refs) != 1 || service.craftReq.Refs[0].MsgID != 116 { + t.Fatalf("upgrade message alias craft: updates=%T req=%+v err=%v calls=%d", updates, service.craftReq, err, service.craftCall) } } @@ -314,6 +315,162 @@ func TestSavedStarGiftProjectionCombinesHistoricalCatalogWithCurrentCollectibleA } } +func TestSavedStarGiftProjectionPreservesCollectibleLifecycle(t *testing.T) { + const ( + giftID = int64(8001) + revision = int64(9001) + readyAt = 1_780_000_123 + exportAt = 1_780_000_200 + transferAt = 1_780_000_300 + resellAt = 1_780_000_400 + ) + unique := domain.UniqueStarGift{ID: 9901, GiftID: giftID, Title: "Craftable", Slug: "craftable-1", Num: 1, + Owner: domain.Peer{Type: domain.PeerTypeUser, ID: 7102}, CraftChancePermille: 250} + saved := domain.SavedStarGift{ + Owner: domain.Peer{Type: domain.PeerTypeUser, ID: 7102}, GiftID: giftID, RevisionID: revision, + MsgID: 44, Date: 100, UniqueGiftID: unique.ID, Unique: &unique, + CanExportAt: exportAt, TransferStars: 25, CanTransferAt: transferAt, CanResellAt: resellAt, + DropOriginalDetailsStars: 30, CanCraftAt: readyAt, + } + projected := tgSavedStarGifts([]domain.SavedStarGift{saved}, nil, nil) + if len(projected) != 1 { + t.Fatalf("saved lifecycle projection count = %d", len(projected)) + } + assertLifecycle := func(t *testing.T, item tg.SavedStarGift) { + t.Helper() + if value, ok := item.GetCanExportAt(); !ok || value != exportAt { + t.Fatalf("can_export_at = %d set=%v", value, ok) + } + if value, ok := item.GetTransferStars(); !ok || value != 25 { + t.Fatalf("transfer_stars = %d set=%v", value, ok) + } + if value, ok := item.GetCanTransferAt(); !ok || value != transferAt { + t.Fatalf("can_transfer_at = %d set=%v", value, ok) + } + if value, ok := item.GetCanResellAt(); !ok || value != resellAt { + t.Fatalf("can_resell_at = %d set=%v", value, ok) + } + if value, ok := item.GetDropOriginalDetailsStars(); !ok || value != 30 { + t.Fatalf("drop_original_details_stars = %d set=%v", value, ok) + } + if value, ok := item.GetCanCraftAt(); !ok || value != readyAt { + t.Fatalf("can_craft_at = %d set=%v", value, ok) + } + } + assertLifecycle(t, projected[0]) + zero := tgSavedStarGifts([]domain.SavedStarGift{{ + Owner: domain.Peer{Type: domain.PeerTypeUser, ID: 7102}, GiftID: giftID, RevisionID: revision, + MsgID: 45, Date: 101, UniqueGiftID: unique.ID, Unique: &unique, + }}, nil, nil)[0] + if _, ok := zero.GetCanExportAt(); ok { + t.Fatal("zero can_export_at must be absent") + } + if _, ok := zero.GetTransferStars(); ok { + t.Fatal("zero transfer_stars must be absent") + } + if _, ok := zero.GetCanTransferAt(); ok { + t.Fatal("zero can_transfer_at must be absent") + } + if _, ok := zero.GetCanResellAt(); ok { + t.Fatal("zero can_resell_at must be absent") + } + if _, ok := zero.GetDropOriginalDetailsStars(); ok { + t.Fatal("zero drop_original_details_stars must be absent") + } + if _, ok := zero.GetCanCraftAt(); ok { + t.Fatal("zero can_craft_at must be absent") + } + channelSaved := saved + channelSaved.Owner = domain.Peer{Type: domain.PeerTypeChannel, ID: 8102} + channelSaved.MsgID = 0 + channelSaved.SavedID = 51 + channelProjected := tgSavedStarGifts([]domain.SavedStarGift{channelSaved}, nil, nil)[0] + if _, ok := channelProjected.GetCanCraftAt(); ok { + t.Fatal("channel can_craft_at must be absent until channel Craft is executable") + } + if value, ok := channelProjected.GetSavedID(); !ok || value != channelSaved.SavedID { + t.Fatalf("channel saved_id = %d set=%v", value, ok) + } + for _, profile := range []tlprofile.Profile{tlprofile.Profile225, tlprofile.Profile226, tlprofile.Profile227, tlprofile.Profile228} { + wire := &tg.PaymentsSavedStarGifts{Count: 1, Gifts: projected, Chats: []tg.ChatClass{}, Users: []tg.UserClass{}} + encoded := &bin.Buffer{} + if err := tlprofile.EncodeObject(profile, wire, encoded); err != nil { + t.Fatalf("encode Layer %d saved lifecycle: %v", profile, err) + } + decodedObject, err := tlprofile.DecodeObject(profile, &bin.Buffer{Buf: encoded.Buf}, tlprofile.Limits{}) + if err != nil { + t.Fatalf("decode Layer %d saved lifecycle: %v", profile, err) + } + decoded, ok := decodedObject.(*tg.PaymentsSavedStarGifts) + if !ok || len(decoded.Gifts) != 1 { + t.Fatalf("decode Layer %d saved lifecycle type = %T", profile, decodedObject) + } + assertLifecycle(t, decoded.Gifts[0]) + + channelWire := &tg.PaymentsSavedStarGifts{Count: 1, Gifts: []tg.SavedStarGift{channelProjected}, Chats: []tg.ChatClass{}, Users: []tg.UserClass{}} + channelEncoded := &bin.Buffer{} + if err := tlprofile.EncodeObject(profile, channelWire, channelEncoded); err != nil { + t.Fatalf("encode Layer %d channel saved lifecycle: %v", profile, err) + } + channelDecodedObject, err := tlprofile.DecodeObject(profile, &bin.Buffer{Buf: channelEncoded.Buf}, tlprofile.Limits{}) + if err != nil { + t.Fatalf("decode Layer %d channel saved lifecycle: %v", profile, err) + } + channelDecoded, ok := channelDecodedObject.(*tg.PaymentsSavedStarGifts) + if !ok || len(channelDecoded.Gifts) != 1 { + t.Fatalf("decode Layer %d channel saved lifecycle type = %T", profile, channelDecodedObject) + } + if _, ok := channelDecoded.Gifts[0].GetCanCraftAt(); ok { + t.Fatalf("Layer %d channel saved gift exposed can_craft_at", profile) + } + } +} + +func TestChannelUniqueActionSuppressesCraftReadinessAcrossProfiles(t *testing.T) { + const readyAt = 1_780_000_123 + unique := domain.UniqueStarGift{ + ID: 9902, GiftID: 8002, Title: "Channel Craftable", Slug: "channel-craftable-1", Num: 1, + Owner: domain.Peer{Type: domain.PeerTypeChannel, ID: 8102}, CraftChancePermille: 250, + } + action := tgMessageActionStarGiftUnique(&domain.MessageStarGiftUniqueAction{ + Gift: unique, Peer: unique.Owner, SavedID: 52, Saved: true, CanCraftAt: readyAt, + }).(*tg.MessageActionStarGiftUnique) + if _, ok := action.GetCanCraftAt(); ok { + t.Fatal("channel unique action must not expose can_craft_at") + } + projectedGift, ok := action.Gift.(*tg.StarGiftUnique) + if !ok || projectedGift.CraftChancePermille != unique.CraftChancePermille { + t.Fatalf("channel unique gift lost intrinsic Craft chance: %#v", action.Gift) + } + for _, profile := range []tlprofile.Profile{tlprofile.Profile225, tlprofile.Profile226, tlprofile.Profile227, tlprofile.Profile228} { + wire := &bin.Buffer{} + if err := tlprofile.EncodeObject(profile, action, wire); err != nil { + t.Fatalf("encode Layer %d channel unique action: %v", profile, err) + } + decodedObject, err := tlprofile.DecodeObject(profile, &bin.Buffer{Buf: wire.Buf}, tlprofile.Limits{}) + if err != nil { + t.Fatalf("decode Layer %d channel unique action: %v", profile, err) + } + decoded, ok := decodedObject.(*tg.MessageActionStarGiftUnique) + if !ok { + t.Fatalf("decode Layer %d channel unique action type = %T", profile, decodedObject) + } + if _, ok := decoded.GetCanCraftAt(); ok { + t.Fatalf("Layer %d channel unique action exposed can_craft_at", profile) + } + gift, ok := decoded.Gift.(*tg.StarGiftUnique) + if !ok || gift.CraftChancePermille != unique.CraftChancePermille { + t.Fatalf("Layer %d channel unique gift = %#v", profile, decoded.Gift) + } + } +} + +func TestStarGiftLifecycleCraftUnavailableError(t *testing.T) { + if err := starGiftLifecycleErr(domain.ErrStarGiftCraftUnavailable); !tgerr.Is(err, "STARGIFT_CRAFT_UNAVAILABLE") { + t.Fatalf("craft unavailable mapping = %v", err) + } +} + func TestMessageStarGiftProjectionSeparatesPaidPriceFromPrepaidAmount(t *testing.T) { ordinary, ok := tgMessageActionStarGift(&domain.MessageStarGiftAction{ GiftID: 8001, Stars: 50, ConvertStars: 25, CanUpgrade: true, UpgradePriceStars: 75, diff --git a/internal/store/memory/star_gift.go b/internal/store/memory/star_gift.go index 2a6733f5..63369feb 100644 --- a/internal/store/memory/star_gift.go +++ b/internal/store/memory/star_gift.go @@ -530,6 +530,15 @@ func (s *StarGiftStore) SetUnsaved(_ context.Context, ref domain.SavedStarGiftRe for i := range s.gifts { if s.savedStarGiftMatchesRef(s.gifts[i], ref) && s.gifts[i].LifecycleStatus.Live() { s.gifts[i].Unsaved = unsaved + if unsaved && s.gifts[i].PinnedOrder > 0 { + removedOrder := s.gifts[i].PinnedOrder + s.gifts[i].PinnedOrder = 0 + for j := range s.gifts { + if s.gifts[j].Owner == ref.Owner && s.gifts[j].PinnedOrder > removedOrder { + s.gifts[j].PinnedOrder-- + } + } + } return true, nil } } @@ -701,10 +710,16 @@ func (s *StarGiftStore) ReorderCollections(_ context.Context, owner domain.Peer, func (s *StarGiftStore) SetPinned(_ context.Context, owner domain.Peer, savedGiftIDs []int64) error { s.mu.Lock() defer s.mu.Unlock() + if len(savedGiftIDs) > domain.MaxPinnedStarGifts { + return domain.ErrStarGiftCollectibleInvalid + } ids, err := s.validCollectionGiftIDsLocked(owner, savedGiftIDs) if err != nil { return err } + if len(ids) != len(savedGiftIDs) { + return domain.ErrStarGiftCollectibleInvalid + } order := make(map[int64]int, len(ids)) for i, id := range ids { order[id] = i + 1 @@ -712,6 +727,9 @@ func (s *StarGiftStore) SetPinned(_ context.Context, owner domain.Peer, savedGif for i := range s.gifts { if s.gifts[i].Owner == owner { s.gifts[i].PinnedOrder = order[s.gifts[i].ID] + if s.gifts[i].PinnedOrder > 0 { + s.gifts[i].Unsaved = false + } } } return nil diff --git a/internal/store/memory/star_gift_profile_order_test.go b/internal/store/memory/star_gift_profile_order_test.go index e7e008e0..0fb02bee 100644 --- a/internal/store/memory/star_gift_profile_order_test.go +++ b/internal/store/memory/star_gift_profile_order_test.go @@ -47,6 +47,24 @@ func TestStarGiftProfilePinOrderAndPagination(t *testing.T) { if !slices.Equal(got, want) { t.Fatalf("paged order = %v, want %v", got, want) } + if ok, err := store.SetUnsaved(ctx, domain.SavedStarGiftRef{Owner: owner, MsgID: 100}, true); err != nil || !ok { + t.Fatalf("hide pinned gift = %v err %v", ok, err) + } + hidden, found, err := store.GetByRef(ctx, domain.SavedStarGiftRef{Owner: owner, MsgID: 100}) + if err != nil || !found || !hidden.Unsaved || hidden.PinnedOrder != 0 { + t.Fatalf("hidden pinned gift = %+v found %v err %v", hidden, found, err) + } + remaining, found, err := store.GetByRef(ctx, domain.SavedStarGiftRef{Owner: owner, MsgID: 102}) + if err != nil || !found || remaining.PinnedOrder != 1 { + t.Fatalf("remaining pin = %+v found %v err %v", remaining, found, err) + } + if err := store.SetPinned(ctx, owner, []int64{ids[0], ids[2]}); err != nil { + t.Fatalf("repin hidden gift: %v", err) + } + repinned, found, err := store.GetByRef(ctx, domain.SavedStarGiftRef{Owner: owner, MsgID: 100}) + if err != nil || !found || repinned.Unsaved || repinned.PinnedOrder != 1 { + t.Fatalf("repinned gift = %+v found %v err %v", repinned, found, err) + } if err := store.SetPinned(ctx, owner, nil); err != nil { t.Fatalf("clear pinned: %v", err) diff --git a/internal/store/postgres/star_gift.go b/internal/store/postgres/star_gift.go index 7f83fdb3..c488f6cd 100644 --- a/internal/store/postgres/star_gift.go +++ b/internal/store/postgres/star_gift.go @@ -649,11 +649,16 @@ LEFT JOIN unique_star_gifts u ON u.id=p.unique_gift_id WHERE p.owner_peer_type=$1 AND p.owner_peer_id=$2 AND p.lifecycle_status='active' AND (p.saved_id::bigint=ANY($3::bigint[]) OR u.slug=ANY($4::text[]))` if owner.Type == domain.PeerTypeUser { - query = `SELECT p.msg_id::bigint, COALESCE(u.slug, ''), p.id + query = `SELECT ref.msg_id, COALESCE(u.slug, ''), p.id FROM peer_star_gifts p LEFT JOIN unique_star_gifts u ON u.id=p.unique_gift_id +CROSS JOIN LATERAL ( + SELECT p.msg_id::bigint AS msg_id + UNION ALL + SELECT r.msg_id::bigint FROM star_gift_user_message_refs r WHERE r.saved_gift_id=p.id +) ref WHERE p.owner_peer_type=$1 AND p.owner_peer_id=$2 AND p.lifecycle_status='active' - AND (p.msg_id::bigint=ANY($3::bigint[]) + AND (ref.msg_id=ANY($3::bigint[]) OR u.slug=ANY($4::text[]))` } rows, err := s.db.Query(ctx, query, string(owner.Type), owner.ID, values, slugs) @@ -744,15 +749,42 @@ func (s *StarGiftStore) SetUnsaved(ctx context.Context, ref domain.SavedStarGift if !ref.Valid() { return false, domain.ErrStarGiftNotFound } - where, args := savedStarGiftRefWhere(ref) - args = append(args, unsaved) - tag, err := s.db.Exec(ctx, ` -UPDATE peer_star_gifts SET unsaved = $4 -WHERE `+where+` AND lifecycle_status='active'`, args...) + changed := false + err := withTx(ctx, s.db, "set star gift unsaved", func(tx pgx.Tx) error { + if _, err := tx.Exec(ctx, `SELECT pg_advisory_xact_lock(hashtextextended($1, 0))`, starGiftCollectionLockKey(ref.Owner)); err != nil { + return err + } + where, args := savedStarGiftRefWhere(ref) + var savedID int64 + var pinnedOrder int + err := tx.QueryRow(ctx, `SELECT id,pinned_order FROM peer_star_gifts WHERE `+where+` AND lifecycle_status='active' FOR UPDATE`, args...).Scan(&savedID, &pinnedOrder) + if errors.Is(err, pgx.ErrNoRows) { + return nil + } + if err != nil { + return err + } + if _, err := tx.Exec(ctx, `UPDATE peer_star_gifts SET unsaved=$2,pinned_order=CASE WHEN $2 THEN 0 ELSE pinned_order END WHERE id=$1`, savedID, unsaved); err != nil { + return err + } + if unsaved && pinnedOrder > 0 { + // The positive-order unique index is immediate. Move the bounded + // vector one vacant slot at a time so no transient duplicate order + // can be observed by PostgreSQL. + for order := pinnedOrder + 1; order <= domain.MaxPinnedStarGifts; order++ { + if _, err := tx.Exec(ctx, `UPDATE peer_star_gifts SET pinned_order=$4 +WHERE owner_peer_type=$1 AND owner_peer_id=$2 AND pinned_order=$3`, string(ref.Owner.Type), ref.Owner.ID, order, order-1); err != nil { + return err + } + } + } + changed = true + return nil + }) if err != nil { return false, fmt.Errorf("set star gift unsaved: %w", err) } - return tag.RowsAffected() > 0, nil + return changed, nil } func (s *StarGiftStore) MarkConverted(ctx context.Context, ref domain.SavedStarGiftRef) (domain.SavedStarGift, error) { @@ -836,7 +868,11 @@ func savedStarGiftRefWhere(ref domain.SavedStarGiftRef) (string, []any) { return "owner_peer_type = $1 AND owner_peer_id = $2 AND saved_id = $3", args default: args = append(args, ref.MsgID) - return "owner_peer_type = $1 AND owner_peer_id = $2 AND msg_id = $3", args + return `owner_peer_type = $1 AND owner_peer_id = $2 AND ( +msg_id = $3 OR EXISTS ( + SELECT 1 FROM star_gift_user_message_refs r + WHERE r.saved_gift_id = id AND r.owner_user_id = $2 AND r.msg_id = $3 +))`, args } } diff --git a/internal/store/postgres/star_gift_collectibles.go b/internal/store/postgres/star_gift_collectibles.go index 7bad16aa..d942b863 100644 --- a/internal/store/postgres/star_gift_collectibles.go +++ b/internal/store/postgres/star_gift_collectibles.go @@ -690,6 +690,9 @@ func (s *StarGiftStore) ReorderCollections(ctx context.Context, owner domain.Pee } func (s *StarGiftStore) SetPinned(ctx context.Context, owner domain.Peer, savedGiftIDs []int64) error { + if len(savedGiftIDs) > domain.MaxPinnedStarGifts { + return domain.ErrStarGiftCollectibleInvalid + } return withTx(ctx, s.db, "set pinned star gifts", func(tx pgx.Tx) error { if _, err := tx.Exec(ctx, `SELECT pg_advisory_xact_lock(hashtextextended($1, 0))`, starGiftCollectionLockKey(owner)); err != nil { return err @@ -698,11 +701,14 @@ func (s *StarGiftStore) SetPinned(ctx context.Context, owner domain.Peer, savedG if err != nil { return err } + if len(ids) != len(savedGiftIDs) { + return domain.ErrStarGiftCollectibleInvalid + } if _, err := tx.Exec(ctx, `UPDATE peer_star_gifts SET pinned_order=0 WHERE owner_peer_type=$1 AND owner_peer_id=$2 AND pinned_order<>0`, string(owner.Type), owner.ID); err != nil { return err } for order, id := range ids { - if _, err := tx.Exec(ctx, `UPDATE peer_star_gifts SET pinned_order=$2 WHERE id=$1`, id, order+1); err != nil { + if _, err := tx.Exec(ctx, `UPDATE peer_star_gifts SET pinned_order=$2,unsaved=false WHERE id=$1`, id, order+1); err != nil { return err } } diff --git a/internal/store/postgres/star_gift_collectibles_integration_test.go b/internal/store/postgres/star_gift_collectibles_integration_test.go index a9ee78a3..b73f8106 100644 --- a/internal/store/postgres/star_gift_collectibles_integration_test.go +++ b/internal/store/postgres/star_gift_collectibles_integration_test.go @@ -116,13 +116,28 @@ func TestStarGiftCollectibleUpgradeAggregatePostgres(t *testing.T) { t.Fatalf("owner upgrade service message = %+v", ownerMessage) } uniqueAction := ownerMessage.Media.ServiceAction.StarGiftUnique - if uniqueAction.SavedID != int64(saved.MsgID) { - t.Fatalf("unique action saved_id = %d, want stable source msg id %d", uniqueAction.SavedID, saved.MsgID) + if uniqueAction.SavedID != 0 || uniqueAction.Peer.Type != "" || uniqueAction.Peer.ID != 0 { + t.Fatalf("user unique action leaked channel peer/saved_id: %+v", uniqueAction) } senderUniqueAction := upgraded.Send.SenderMessage.Media.ServiceAction.StarGiftUnique if senderUniqueAction == nil || senderUniqueAction.SavedID != 0 { t.Fatalf("sender unique action leaked owner-only saved_id: %+v", senderUniqueAction) } + if byOutput, found, err := gifts.GetByRef(ctx, domain.SavedStarGiftRef{Owner: ownerPeer, MsgID: ownerMessage.ID}); err != nil || !found || byOutput.ID != savedID { + t.Fatalf("owner upgrade output ref = %+v found %v err %v", byOutput, found, err) + } + var ownerAliasCount, senderAliasCount int + if err := pool.QueryRow(ctx, `SELECT COUNT(*) FROM star_gift_user_message_refs +WHERE owner_user_id=$1 AND msg_id=$2 AND saved_gift_id=$3`, owner.ID, ownerMessage.ID, savedID).Scan(&ownerAliasCount); err != nil { + t.Fatalf("load owner upgrade output alias: %v", err) + } + if err := pool.QueryRow(ctx, `SELECT COUNT(*) FROM star_gift_user_message_refs +WHERE owner_user_id=$1 AND msg_id=$2`, sender.ID, ownerMessage.ID).Scan(&senderAliasCount); err != nil { + t.Fatalf("load sender upgrade output alias: %v", err) + } + if ownerAliasCount != 1 || senderAliasCount != 0 { + t.Fatalf("upgrade output aliases owner=%d sender=%d, want owner-only", ownerAliasCount, senderAliasCount) + } ownerSourceEdit := upgradedSourceEditForUser(upgraded, owner.ID) if ownerSourceEdit.Event.Pts <= ownerMessage.Pts || ownerSourceEdit.Message.Media == nil || ownerSourceEdit.Message.Media.ServiceAction == nil || ownerSourceEdit.Message.Media.ServiceAction.StarGift == nil || diff --git a/internal/store/postgres/star_gift_craft_auction.go b/internal/store/postgres/star_gift_craft_auction.go index 229ba392..e55e9779 100644 --- a/internal/store/postgres/star_gift_craft_auction.go +++ b/internal/store/postgres/star_gift_craft_auction.go @@ -11,6 +11,7 @@ import ( "github.com/jackc/pgx/v5" "telesrv/internal/domain" + "telesrv/internal/store" ) const ( @@ -18,6 +19,37 @@ const ( maxStarGiftAuctionAcquired = 1000 ) +// starGiftCraftOutputIntent is the immutable message intent committed with a +// successful craft outcome. The aggregate may subsequently be hidden, listed +// or otherwise edited; an exact retry must still use the first intent and its +// fingerprint instead of rebuilding a different message from mutable state. +type starGiftCraftOutputIntent struct { + Media *domain.MessageMedia + Fingerprint []byte + Date int + SavedGiftID int64 +} + +func starGiftCraftOutputMedia(userID int64, gift domain.UniqueStarGift, saved domain.SavedStarGift) *domain.MessageMedia { + return &domain.MessageMedia{Kind: domain.MessageMediaKindService, ServiceAction: &domain.MessageServiceAction{ + Kind: domain.MessageServiceActionStarGiftUnique, StarGiftUnique: &domain.MessageStarGiftUniqueAction{ + Gift: gift, FromUserID: userID, Saved: !saved.Unsaved, Craft: true, + CanExportAt: saved.CanExportAt, TransferStars: saved.TransferStars, CanTransferAt: saved.CanTransferAt, + CanResellAt: saved.CanResellAt, DropOriginalDetailsStars: saved.DropOriginalDetailsStars, + CanCraftAt: saved.CanCraftAt, + }, + }} +} + +func starGiftCraftOutputRequest(req domain.StarGiftCraftRequest, intent starGiftCraftOutputIntent) domain.SendPrivateTextRequest { + return domain.SendPrivateTextRequest{ + SenderUserID: req.UserID, RecipientUserID: req.UserID, + RandomID: lifecycleCommandRandomID("craft", req.UserID, req.CommandKey), Date: intent.Date, + OriginAuthKeyID: req.OriginAuthKeyID, OriginSessionID: req.OriginSessionID, OriginUserID: req.UserID, + IdempotencyFingerprint: append([]byte(nil), intent.Fingerprint...), Media: intent.Media, + } +} + func defaultStarGiftCraftDraw(upper int) (int, error) { if upper <= 0 { return 0, domain.ErrStarGiftCraftUnavailable @@ -158,7 +190,8 @@ func (s *StarGiftLifecycleStore) ListCraftStarGifts(ctx context.Context, userID, } args := []any{userID, giftID} where := `p.owner_peer_type='user' AND p.owner_peer_id=$1 AND p.gift_id=$2 - AND p.lifecycle_status='active' AND p.unique_gift_id IS NOT NULL AND p.can_craft_at<=EXTRACT(EPOCH FROM now())::integer + AND p.lifecycle_status='active' AND p.unique_gift_id IS NOT NULL + AND p.can_craft_at>0 AND p.can_craft_at<=EXTRACT(EPOCH FROM now())::integer AND NOT u.burned AND u.owner_address='' AND u.craft_chance_permille>0 AND EXISTS (SELECT 1 FROM star_gift_collectible_models m WHERE m.collectible_revision_id=u.collectible_revision_id AND m.crafted)` @@ -234,11 +267,11 @@ func (s *StarGiftLifecycleStore) CraftStarGift(ctx context.Context, req domain.S // A committed failed craft has already moved every input out of the active // lifecycle. Consult the immutable receipt before active-gift resolution so // an exact transport retry can still replay the same terminal result. - if replay, found, err := s.loadCraftReplay(ctx, req); err != nil || found { + if replay, output, found, err := s.loadCraftReplay(ctx, req); err != nil || found { if err != nil || !replay.Success { return replay, err } - return s.deliverCraftSuccess(ctx, req, replay) + return s.deliverCraftSuccess(ctx, req, replay, output) } savedIDs, err := NewStarGiftStore(s.db).ResolveSavedIDs(ctx, owner, req.Refs) if err != nil { @@ -248,7 +281,7 @@ func (s *StarGiftLifecycleStore) CraftStarGift(ctx context.Context, req domain.S return domain.StarGiftCraftResult{}, domain.ErrStarGiftCraftUnavailable } var result domain.StarGiftCraftResult - var resultUniqueID int64 + var output starGiftCraftOutputIntent err = withTx(ctx, s.db, "craft star gift", func(tx pgx.Tx) error { lockedRows, err := tx.Query(ctx, `SELECT id FROM peer_star_gifts WHERE id=ANY($1::bigint[]) ORDER BY id FOR UPDATE`, sortedUniqueInt64(savedIDs)) if err != nil { @@ -269,7 +302,7 @@ func (s *StarGiftLifecycleStore) CraftStarGift(ctx context.Context, req domain.S chance := 0 for i := range req.Refs { saved, err := lockSavedStarGiftByID(ctx, tx, savedIDs[i]) - if err != nil || !saved.LifecycleStatus.Live() || saved.UniqueGiftID == 0 || saved.CanCraftAt > req.Date { + if err != nil || !saved.LifecycleStatus.Live() || saved.UniqueGiftID == 0 || saved.CanCraftAt <= 0 || saved.CanCraftAt > req.Date { return domain.ErrStarGiftCraftUnavailable } unique, found, err := NewStarGiftStore(tx).UniqueByID(ctx, saved.UniqueGiftID) @@ -374,111 +407,164 @@ WHERE id=ANY($1::bigint[])`, savedIDs[burnFrom:]); err != nil { } result.SourceEdits = sourceEdits var resultID any + var outputMediaJSON any + var outputFingerprint any if result.Success { resultID = firstUniqueID - resultUniqueID = firstUniqueID + gift, found, err := NewStarGiftStore(tx).UniqueByID(ctx, firstUniqueID) + if err != nil || !found { + if err != nil { + return err + } + return domain.ErrStarGiftCraftUnavailable + } + saved, found, err := savedStarGiftByID(ctx, tx, firstSavedID) + if err != nil || !found || saved.Owner != owner || saved.UniqueGiftID != gift.ID || + !saved.LifecycleStatus.Live() || gift.Owner != owner || gift.Burned || !gift.Crafted { + if err != nil { + return err + } + return domain.ErrStarGiftCraftUnavailable + } + media := starGiftCraftOutputMedia(req.UserID, gift, saved) + intent := starGiftCraftOutputIntent{Media: media, Date: req.Date, SavedGiftID: saved.ID} + fingerprint, err := store.PrivateSendFingerprint(starGiftCraftOutputRequest(req, intent)) + if err != nil { + return fmt.Errorf("fingerprint crafted gift output: %w", err) + } + mediaJSON, err := encodeMessageMedia(media) + if err != nil { + return fmt.Errorf("encode crafted gift output: %w", err) + } + intent.Fingerprint = fingerprint + output = intent + outputMediaJSON = mediaJSON + outputFingerprint = fingerprint + giftCopy := gift + result.Gift = &giftCopy } _, err = tx.Exec(ctx, `INSERT INTO star_gift_craft_commands(user_id,command_key,input_unique_gift_ids,gift_id, -success,result_unique_gift_id,chance_permille,created_at,source_edit_pts) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9)`, req.UserID, - strings.TrimSpace(req.CommandKey), uniqueIDs, giftID, result.Success, resultID, chance, req.Date, sourceEditPTS) +success,result_unique_gift_id,chance_permille,created_at,source_edit_pts,output_media,output_fingerprint) +VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11)`, req.UserID, strings.TrimSpace(req.CommandKey), uniqueIDs, + giftID, result.Success, resultID, chance, req.Date, sourceEditPTS, outputMediaJSON, outputFingerprint) return err }) if err != nil { if isUniqueViolation(err) { - if replay, found, replayErr := s.loadCraftReplay(ctx, req); replayErr != nil || found { + if replay, replayOutput, found, replayErr := s.loadCraftReplay(ctx, req); replayErr != nil || found { + if replayErr == nil && found && replay.Success { + return s.deliverCraftSuccess(ctx, req, replay, replayOutput) + } return replay, replayErr } } return domain.StarGiftCraftResult{}, err } if result.Success { - gift, found, err := NewStarGiftStore(s.db).UniqueByID(ctx, resultUniqueID) - if err != nil || !found { - return domain.StarGiftCraftResult{}, domain.ErrStarGiftCraftUnavailable - } - result.Gift = &gift - } - if result.Success { - return s.deliverCraftSuccess(ctx, req, result) + return s.deliverCraftSuccess(ctx, req, result, output) } return result, nil } -func (s *StarGiftLifecycleStore) deliverCraftSuccess(ctx context.Context, req domain.StarGiftCraftRequest, result domain.StarGiftCraftResult) (domain.StarGiftCraftResult, error) { - if result.Gift == nil || s.messages == nil { +func (s *StarGiftLifecycleStore) deliverCraftSuccess(ctx context.Context, req domain.StarGiftCraftRequest, result domain.StarGiftCraftResult, output starGiftCraftOutputIntent) (domain.StarGiftCraftResult, error) { + if result.Gift == nil || s.messages == nil || output.Media == nil || output.Date <= 0 || output.SavedGiftID <= 0 || + store.ValidateSendFingerprint(output.Fingerprint, "crafted gift output") != nil { return domain.StarGiftCraftResult{}, domain.ErrStarGiftCraftUnavailable } - saved, found, err := savedStarGiftByUniqueID(ctx, s.db, result.Gift.ID) - if err != nil || !found || saved.Owner != (domain.Peer{Type: domain.PeerTypeUser, ID: req.UserID}) { - return domain.StarGiftCraftResult{}, domain.ErrStarGiftCraftUnavailable - } - sent, err := s.messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{SenderUserID: req.UserID, - RecipientUserID: req.UserID, RandomID: lifecycleCommandRandomID("craft", req.UserID, req.CommandKey), Date: req.Date, - OriginAuthKeyID: req.OriginAuthKeyID, OriginSessionID: req.OriginSessionID, OriginUserID: req.UserID, - Media: &domain.MessageMedia{Kind: domain.MessageMediaKindService, ServiceAction: &domain.MessageServiceAction{ - Kind: domain.MessageServiceActionStarGiftUnique, StarGiftUnique: &domain.MessageStarGiftUniqueAction{ - Gift: *result.Gift, FromUserID: req.UserID, Peer: saved.Owner, Saved: !saved.Unsaved, Craft: true, - CanExportAt: saved.CanExportAt, TransferStars: saved.TransferStars, CanTransferAt: saved.CanTransferAt, - CanResellAt: saved.CanResellAt, DropOriginalDetailsStars: saved.DropOriginalDetailsStars, - CanCraftAt: saved.CanCraftAt}}}}) + messageReq := starGiftCraftOutputRequest(req, output) + hooks := privateSendTxHooks{after: func(ctx context.Context, tx pgx.Tx, sent domain.SendPrivateTextResult) error { + return registerUserStarGiftMessageRef(ctx, tx, req.UserID, sent.SenderMessage.ID, output.SavedGiftID, result.Gift.ID) + }} + sent, err := s.messages.sendPrivateTextWithHooks(ctx, messageReq, hooks) if err != nil { return domain.StarGiftCraftResult{}, err } + registered, err := userStarGiftMessageRefMatches(ctx, s.db, req.UserID, sent.SenderMessage.ID, output.SavedGiftID) + if err != nil || !registered { + if err != nil { + return domain.StarGiftCraftResult{}, err + } + return domain.StarGiftCraftResult{}, domain.ErrStarGiftCraftUnavailable + } result.Send = sent result.Duplicate = result.Duplicate || sent.Duplicate return result, nil } -func (s *StarGiftLifecycleStore) loadCraftReplay(ctx context.Context, req domain.StarGiftCraftRequest) (domain.StarGiftCraftResult, bool, error) { +func (s *StarGiftLifecycleStore) loadCraftReplay(ctx context.Context, req domain.StarGiftCraftRequest) (domain.StarGiftCraftResult, starGiftCraftOutputIntent, bool, error) { var success bool var resultID *int64 var chance int var inputUniqueIDs []int64 var sourceEditPTS []int32 - err := s.db.QueryRow(ctx, `SELECT input_unique_gift_ids,success,result_unique_gift_id,chance_permille,source_edit_pts + var createdAt int + var outputMediaJSON, outputFingerprint []byte + err := s.db.QueryRow(ctx, `SELECT input_unique_gift_ids,success,result_unique_gift_id,chance_permille,source_edit_pts, +created_at,output_media,output_fingerprint FROM star_gift_craft_commands WHERE user_id=$1 AND command_key=$2`, - req.UserID, strings.TrimSpace(req.CommandKey)).Scan(&inputUniqueIDs, &success, &resultID, &chance, &sourceEditPTS) + req.UserID, strings.TrimSpace(req.CommandKey)).Scan(&inputUniqueIDs, &success, &resultID, &chance, &sourceEditPTS, + &createdAt, &outputMediaJSON, &outputFingerprint) if errors.Is(err, pgx.ErrNoRows) { - return domain.StarGiftCraftResult{}, false, nil + return domain.StarGiftCraftResult{}, starGiftCraftOutputIntent{}, false, nil } if err != nil { - return domain.StarGiftCraftResult{}, false, err + return domain.StarGiftCraftResult{}, starGiftCraftOutputIntent{}, false, err } if len(req.Refs) != len(inputUniqueIDs) || len(req.Refs) != len(sourceEditPTS) { - return domain.StarGiftCraftResult{}, false, domain.ErrStarGiftCraftUnavailable + return domain.StarGiftCraftResult{}, starGiftCraftOutputIntent{}, false, domain.ErrStarGiftCraftUnavailable } savedIDs := make([]int64, 0, len(inputUniqueIDs)) owner := domain.Peer{Type: domain.PeerTypeUser, ID: req.UserID} for i, uniqueID := range inputUniqueIDs { saved, found, err := savedStarGiftByUniqueID(ctx, s.db, uniqueID) if err != nil || !found || saved.Owner != owner || saved.UniqueGiftID != uniqueID { - return domain.StarGiftCraftResult{}, false, domain.ErrStarGiftCraftUnavailable + return domain.StarGiftCraftResult{}, starGiftCraftOutputIntent{}, false, domain.ErrStarGiftCraftUnavailable } ref := req.Refs[i] - if ref.Owner != owner || ref.Slug == "" && ref.MsgID != saved.MsgID { - return domain.StarGiftCraftResult{}, false, domain.ErrStarGiftCraftUnavailable + if ref.Owner != owner { + return domain.StarGiftCraftResult{}, starGiftCraftOutputIntent{}, false, domain.ErrStarGiftCraftUnavailable } if ref.Slug != "" { unique, found, err := NewStarGiftStore(s.db).UniqueByID(ctx, uniqueID) if err != nil || !found || !strings.EqualFold(ref.Slug, unique.Slug) { - return domain.StarGiftCraftResult{}, false, domain.ErrStarGiftCraftUnavailable + return domain.StarGiftCraftResult{}, starGiftCraftOutputIntent{}, false, domain.ErrStarGiftCraftUnavailable + } + } else if ref.MsgID != saved.MsgID { + matches, err := userStarGiftMessageRefMatches(ctx, s.db, req.UserID, ref.MsgID, saved.ID) + if err != nil { + return domain.StarGiftCraftResult{}, starGiftCraftOutputIntent{}, false, err + } + if !matches { + return domain.StarGiftCraftResult{}, starGiftCraftOutputIntent{}, false, domain.ErrStarGiftCraftUnavailable } } savedIDs = append(savedIDs, saved.ID) } sourceEdits, err := s.loadCraftInputMessageReplays(ctx, req, savedIDs, sourceEditPTS) if err != nil { - return domain.StarGiftCraftResult{}, false, err + return domain.StarGiftCraftResult{}, starGiftCraftOutputIntent{}, false, err } result := domain.StarGiftCraftResult{Success: success, Chance: chance, SourceEdits: sourceEdits, Duplicate: true} - if resultID != nil { - gift, found, err := NewStarGiftStore(s.db).UniqueByID(ctx, *resultID) - if err != nil || !found { - return domain.StarGiftCraftResult{}, false, domain.ErrStarGiftCraftUnavailable + if !success { + if resultID != nil || len(outputMediaJSON) != 0 || len(outputFingerprint) != 0 { + return domain.StarGiftCraftResult{}, starGiftCraftOutputIntent{}, false, domain.ErrStarGiftCraftUnavailable } - result.Gift = &gift + return result, starGiftCraftOutputIntent{}, true, nil } - return result, true, nil + if resultID == nil || createdAt <= 0 || store.ValidateSendFingerprint(outputFingerprint, "crafted gift replay") != nil { + return domain.StarGiftCraftResult{}, starGiftCraftOutputIntent{}, false, domain.ErrStarGiftCraftUnavailable + } + media, err := decodeMessageMedia(string(outputMediaJSON)) + if err != nil || media == nil || media.Kind != domain.MessageMediaKindService || media.ServiceAction == nil || + media.ServiceAction.Kind != domain.MessageServiceActionStarGiftUnique || media.ServiceAction.StarGiftUnique == nil || + !media.ServiceAction.StarGiftUnique.Craft || media.ServiceAction.StarGiftUnique.Gift.ID != *resultID { + return domain.StarGiftCraftResult{}, starGiftCraftOutputIntent{}, false, domain.ErrStarGiftCraftUnavailable + } + gift := media.ServiceAction.StarGiftUnique.Gift + result.Gift = &gift + output := starGiftCraftOutputIntent{Media: media, Fingerprint: append([]byte(nil), outputFingerprint...), + Date: createdAt, SavedGiftID: savedIDs[0]} + return result, output, true, nil } func chooseCraftedModel(ctx context.Context, tx pgx.Tx, revisionID int64) (int64, error) { diff --git a/internal/store/postgres/star_gift_integration_test.go b/internal/store/postgres/star_gift_integration_test.go index 17ba3ce1..bcbdb65e 100644 --- a/internal/store/postgres/star_gift_integration_test.go +++ b/internal/store/postgres/star_gift_integration_test.go @@ -142,6 +142,26 @@ func TestStarGiftStorePostgres(t *testing.T) { if !slices.Equal(gotMsgIDs, wantMsgIDs) { t.Fatalf("pinned paged msg ids = %v, want %v", gotMsgIDs, wantMsgIDs) } + // Hiding a pinned gift atomically unpins it and compacts the remaining + // vector. Pinning it again makes it visible in the same owner transaction. + if ok, err := st.SetUnsaved(ctx, domain.SavedStarGiftRef{Owner: ownerPeer, MsgID: 100}, true); err != nil || !ok { + t.Fatalf("hide pinned gift = %v err %v", ok, err) + } + hiddenPinned, found, err := st.GetByRef(ctx, domain.SavedStarGiftRef{Owner: ownerPeer, MsgID: 100}) + if err != nil || !found || !hiddenPinned.Unsaved || hiddenPinned.PinnedOrder != 0 { + t.Fatalf("hidden pinned gift = %+v found %v err %v", hiddenPinned, found, err) + } + remainingPinned, found, err := st.GetByRef(ctx, domain.SavedStarGiftRef{Owner: ownerPeer, MsgID: 102}) + if err != nil || !found || remainingPinned.PinnedOrder != 1 { + t.Fatalf("remaining pin after compaction = %+v found %v err %v", remainingPinned, found, err) + } + if err := st.SetPinned(ctx, ownerPeer, []int64{savedIDs[0], savedIDs[2]}); err != nil { + t.Fatalf("repin hidden gift: %v", err) + } + repinned, found, err := st.GetByRef(ctx, domain.SavedStarGiftRef{Owner: ownerPeer, MsgID: 100}) + if err != nil || !found || repinned.Unsaved || repinned.PinnedOrder != 1 { + t.Fatalf("repinned hidden gift = %+v found %v err %v", repinned, found, err) + } if err := st.SetPinned(ctx, ownerPeer, nil); err != nil { t.Fatalf("clear pinned profile order: %v", err) } diff --git a/internal/store/postgres/star_gift_lifecycle.go b/internal/store/postgres/star_gift_lifecycle.go index 80b64069..d1d9aca7 100644 --- a/internal/store/postgres/star_gift_lifecycle.go +++ b/internal/store/postgres/star_gift_lifecycle.go @@ -389,6 +389,7 @@ func (s *StarGiftLifecycleStore) TransferStarGift(ctx context.Context, req domai }}, } var result domain.StarGiftTransferResult + var sourceSaved domain.SavedStarGift hooks := privateSendTxHooks{ before: func(ctx context.Context, tx pgx.Tx, send *domain.SendPrivateTextRequest) error { saved, unique, err := lockTransferableStarGift(ctx, tx, req.ActorUserID, req.Ref, req.Date) @@ -414,6 +415,7 @@ func (s *StarGiftLifecycleStore) TransferStarGift(ctx context.Context, req domai if err := removeSavedGiftFromCollections(ctx, tx, saved.Owner, saved.ID); err != nil { return err } + sourceSaved = saved unique.Owner = req.To saved.Owner = req.To result.Saved, result.Unique, result.Balance = saved, unique, balance @@ -433,6 +435,9 @@ func (s *StarGiftLifecycleStore) TransferStarGift(ctx context.Context, req domai can_transfer_at=0 WHERE id=$1`, result.Saved.ID, req.To.ID, req.ActorUserID, msgID, req.Date); err != nil { return err } + if err := registerUserStarGiftMessageRef(ctx, tx, req.To.ID, msgID, result.Saved.ID, result.Unique.ID); err != nil { + return err + } if _, err := tx.Exec(ctx, `INSERT INTO star_gift_transfer_commands(actor_user_id,command_key,unique_gift_id, from_peer_type,from_peer_id,to_peer_type,to_peer_id,charge_stars,balance_after,created_at) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)`, req.ActorUserID, strings.TrimSpace(req.CommandKey), result.Unique.ID, @@ -441,6 +446,10 @@ func (s *StarGiftLifecycleStore) TransferStarGift(ctx context.Context, req domai } result.Saved.MsgID, result.Saved.SavedID, result.Saved.UpgradeMsgID, result.Saved.Date = msgID, 0, msgID, req.Date result.Saved.FromUserID = req.ActorUserID + if sourceSaved.Owner.Type == domain.PeerTypeUser { + _, err := s.retireUserStarGiftMessagesTx(ctx, tx, sourceSaved, result.Unique, req.Date) + return err + } return nil }, } @@ -502,6 +511,7 @@ func (s *StarGiftLifecycleStore) PurchaseResaleStarGift(ctx context.Context, req } var result domain.StarGiftTransferResult var commissionAmount int64 + var sourceSaved domain.SavedStarGift hooks := privateSendTxHooks{ before: func(ctx context.Context, tx pgx.Tx, send *domain.SendPrivateTextRequest) error { var listingCurrency, sellerType string @@ -557,12 +567,15 @@ func (s *StarGiftLifecycleStore) PurchaseResaleStarGift(ctx context.Context, req gift.ResellAmount = nil gift.LastSaleDate = req.Date gift.LastSaleAmount = &domain.StarGiftAmount{Currency: req.Amount.Currency, Amount: req.Amount.Amount} + sourceSaved = saved saved.Owner = req.To if req.To.Type == domain.PeerTypeChannel { saved.MsgID, saved.SavedID = 0, saved.ID } result.Saved, result.Unique, result.Balance = saved, gift, balance send.Media.ServiceAction.StarGiftUnique = transferUniqueAction(gift, messageSenderID, req.To, saved) + resaleAmount := req.Amount + send.Media.ServiceAction.StarGiftUnique.ResaleAmount = &resaleAmount return nil }, after: func(ctx context.Context, tx pgx.Tx, sent domain.SendPrivateTextResult) error { @@ -578,6 +591,11 @@ func (s *StarGiftLifecycleStore) PurchaseResaleStarGift(ctx context.Context, req WHERE id=$1`, result.Saved.ID, string(req.To.Type), req.To.ID, messageSenderID, msgID, savedID, req.Date); err != nil { return err } + if req.To.Type == domain.PeerTypeUser { + if err := registerUserStarGiftMessageRef(ctx, tx, req.To.ID, msgID, result.Saved.ID, result.Unique.ID); err != nil { + return err + } + } if _, err := tx.Exec(ctx, `INSERT INTO star_gift_sales(unique_gift_id,seller_peer_type,seller_peer_id, buyer_peer_type,buyer_peer_id,currency,amount,commission_amount,sold_at,command_key) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)`, result.Unique.ID, string(seller.Type), seller.ID, @@ -601,6 +619,11 @@ func (s *StarGiftLifecycleStore) PurchaseResaleStarGift(ctx context.Context, req } result.Saved.MsgID, result.Saved.SavedID, result.Saved.UpgradeMsgID, result.Saved.Date = msgID, savedID, msgID, req.Date result.Saved.FromUserID = messageSenderID + if sourceSaved.Owner.Type == domain.PeerTypeUser { + if _, err := s.retireUserStarGiftMessagesTx(ctx, tx, sourceSaved, result.Unique, req.Date); err != nil { + return err + } + } return updateStarGiftResaleProjection(ctx, tx, result.Unique.GiftID) }, } @@ -803,7 +826,7 @@ func (s *StarGiftLifecycleStore) ResolveStarGiftOffer(ctx context.Context, req d offer.Gift = gift actionKind := domain.MessageServiceActionStarGiftUnique action := &domain.MessageServiceAction{Kind: actionKind, StarGiftUnique: &domain.MessageStarGiftUniqueAction{ - Gift: gift, FromUserID: req.OwnerUserID, Peer: domain.Peer{Type: domain.PeerTypeUser, ID: offer.BuyerUserID}, + Gift: gift, FromUserID: req.OwnerUserID, Transferred: true, FromOffer: true, Saved: true, }} if req.Decline { @@ -816,6 +839,7 @@ func (s *StarGiftLifecycleStore) ResolveStarGiftOffer(ctx context.Context, req d Media: &domain.MessageMedia{Kind: domain.MessageMediaKindService, ServiceAction: action}} var result domain.StarGiftOfferResult var commissionAmount int64 + var sourceSaved domain.SavedStarGift hooks := privateSendTxHooks{ before: func(ctx context.Context, tx pgx.Tx, send *domain.SendPrivateTextRequest) error { locked, err := scanStarGiftOffer(tx.QueryRow(ctx, `SELECT id,buyer_user_id,owner_peer_type,owner_peer_id,unique_gift_id, @@ -874,10 +898,15 @@ func (s *StarGiftLifecycleStore) ResolveStarGiftOffer(ctx context.Context, req d current.LastSaleDate = req.Date current.LastSaleAmount = &locked.Price locked.Status, locked.ResolvedAt, locked.Gift = "accepted", req.Date, current + sourceSaved = saved + saved.Owner = domain.Peer{Type: domain.PeerTypeUser, ID: locked.BuyerUserID} result.Offer = locked result.Unique = current result.Saved = saved - send.Media.ServiceAction.StarGiftUnique.Gift = current + send.Media.ServiceAction.StarGiftUnique = transferUniqueAction(current, req.OwnerUserID, saved.Owner, saved) + send.Media.ServiceAction.StarGiftUnique.FromOffer = true + resaleAmount := locked.Price + send.Media.ServiceAction.StarGiftUnique.ResaleAmount = &resaleAmount return nil }, after: func(ctx context.Context, tx pgx.Tx, sent domain.SendPrivateTextResult) error { @@ -890,6 +919,10 @@ func (s *StarGiftLifecycleStore) ResolveStarGiftOffer(ctx context.Context, req d WHERE id=$1`, result.Saved.ID, result.Offer.BuyerUserID, req.OwnerUserID, msgID, req.Date); err != nil { return err } + if err := registerUserStarGiftMessageRef(ctx, tx, result.Offer.BuyerUserID, msgID, + result.Saved.ID, result.Unique.ID); err != nil { + return err + } result.Saved.Owner = domain.Peer{Type: domain.PeerTypeUser, ID: result.Offer.BuyerUserID} result.Saved.FromUserID, result.Saved.MsgID, result.Saved.SavedID, result.Saved.UpgradeMsgID, result.Saved.Date = req.OwnerUserID, msgID, 0, msgID, req.Date if _, err := tx.Exec(ctx, `INSERT INTO star_gift_sales(unique_gift_id,seller_peer_type,seller_peer_id, @@ -899,6 +932,9 @@ func (s *StarGiftLifecycleStore) ResolveStarGiftOffer(ctx context.Context, req d req.Date, fmt.Sprintf("offer:%d", result.Offer.ID)); err != nil { return err } + if _, err := s.retireUserStarGiftMessagesTx(ctx, tx, sourceSaved, result.Unique, req.Date); err != nil { + return err + } return updateStarGiftResaleProjection(ctx, tx, result.Offer.Gift.GiftID) }, } @@ -1077,6 +1113,7 @@ func (s *StarGiftLifecycleStore) transferStarGiftWithoutPrivateMessage(ctx conte if err := removeSavedGiftFromCollections(ctx, tx, saved.Owner, saved.ID); err != nil { return err } + sourceSaved := saved if _, err := tx.Exec(ctx, `UPDATE unique_star_gifts SET owner_peer_type='channel',owner_peer_id=$2,updated_at=now() WHERE id=$1`, unique.ID, req.To.ID); err != nil { return err } @@ -1100,6 +1137,11 @@ func (s *StarGiftLifecycleStore) transferStarGiftWithoutPrivateMessage(ctx conte return err } result.Saved, result.Unique, result.Balance = saved, unique, balance + if sourceSaved.Owner.Type == domain.PeerTypeUser { + if _, err := s.retireUserStarGiftMessagesTx(ctx, tx, sourceSaved, unique, req.Date); err != nil { + return err + } + } return nil }) return result, err @@ -1148,17 +1190,23 @@ func ensureNoStarGiftMarketConflict(ctx context.Context, tx pgx.Tx, uniqueID int } func transferUniqueAction(unique domain.UniqueStarGift, fromUserID int64, to domain.Peer, saved domain.SavedStarGift) *domain.MessageStarGiftUniqueAction { + peer := to savedID := saved.SavedID + canCraftAt := saved.CanCraftAt if to.Type == domain.PeerTypeUser { - // For a user-owned transferred gift the action message itself becomes - // inputSavedStarGiftUser.msg_id. A channel saved_id belongs to a different - // identity namespace and must never leak into the recipient's user view. + // peer and saved_id are a shared channel-only TL flag. A user-owned + // transferred gift is managed by this action message's id. + peer = domain.Peer{} savedID = 0 + } else { + // Preserve the durable entitlement for a future transfer back to a user, + // but keep channel Craft hidden until its write/update path exists. + canCraftAt = 0 } - return &domain.MessageStarGiftUniqueAction{Gift: unique, FromUserID: fromUserID, Peer: to, + return &domain.MessageStarGiftUniqueAction{Gift: unique, FromUserID: fromUserID, Peer: peer, SavedID: savedID, Transferred: true, Saved: true, CanExportAt: saved.CanExportAt, TransferStars: saved.TransferStars, CanTransferAt: saved.CanTransferAt, CanResellAt: saved.CanResellAt, - DropOriginalDetailsStars: saved.DropOriginalDetailsStars, CanCraftAt: saved.CanCraftAt} + DropOriginalDetailsStars: saved.DropOriginalDetailsStars, CanCraftAt: canCraftAt} } func (s *StarGiftLifecycleStore) debitLifecycleAmount(ctx context.Context, tx pgx.Tx, userID int64, amount domain.StarGiftAmount, @@ -1468,15 +1516,22 @@ WHERE provider_request_id=$1 FOR UPDATE`, providerRequestID).Scan(&uniqueID, &ow requestHash := sha256.Sum256([]byte(providerRequestID)) giftAddress := fmt.Sprintf("telesrv-gift:%s:%x", unique.Slug, requestHash[:8]) if _, err := tx.Exec(ctx, `UPDATE unique_star_gifts SET owner_peer_type=NULL,owner_peer_id=NULL, -owner_address=$2,gift_address=$3,updated_at=now() WHERE id=$1`, uniqueID, ownerAddress, giftAddress); err != nil { +owner_address=$2,gift_address=$3,craft_chance_permille=0,updated_at=now() WHERE id=$1`, uniqueID, ownerAddress, giftAddress); err != nil { return err } - if _, err := tx.Exec(ctx, `UPDATE peer_star_gifts SET lifecycle_status='exported',unsaved=true,pinned_order=0 WHERE id=$1`, saved.ID); err != nil { + if _, err := tx.Exec(ctx, `UPDATE peer_star_gifts SET lifecycle_status='exported',unsaved=true,pinned_order=0,can_craft_at=0 WHERE id=$1`, saved.ID); err != nil { return err } if _, err := tx.Exec(ctx, `UPDATE star_gift_withdrawal_requests SET status='completed',completed_at=$2 WHERE provider_request_id=$1`, providerRequestID, date); err != nil { return err } + unique.Owner = domain.Peer{} + unique.OwnerAddress = ownerAddress + unique.GiftAddress = giftAddress + unique.CraftChancePermille = 0 + if _, err := s.retireUserStarGiftMessagesTx(ctx, tx, saved, unique, date); err != nil { + return err + } return updateStarGiftResaleProjection(ctx, tx, unique.GiftID) }) if err != nil { diff --git a/internal/store/postgres/star_gift_lifecycle_integration_test.go b/internal/store/postgres/star_gift_lifecycle_integration_test.go index fdec70bd..2102abc6 100644 --- a/internal/store/postgres/star_gift_lifecycle_integration_test.go +++ b/internal/store/postgres/star_gift_lifecycle_integration_test.go @@ -140,14 +140,61 @@ WHERE b.owner_user_id=$1 AND b.box_id=$2`, owner.ID, prepaid.Send.RecipientMessa t.Fatalf("upgrade prepaid gift: %v", err) } if upgraded.Saved.TransferStars != 25 || upgraded.Saved.DropOriginalDetailsStars != 25 || + upgraded.Saved.CanCraftAt != now+2 || upgraded.Unique.CraftChancePermille != 500 || !upgraded.Unique.KeepOriginalDetails { t.Fatalf("issued lifecycle snapshot = saved %+v unique %+v", upgraded.Saved, upgraded.Unique) } + readinessTx, err := pool.Begin(ctx) + if err != nil { + t.Fatalf("begin craft readiness guard probe: %v", err) + } + if _, err = readinessTx.Exec(ctx, `UPDATE peer_star_gifts SET can_craft_at=0 WHERE id=$1`, upgraded.Saved.ID); err != nil { + _ = readinessTx.Rollback(ctx) + t.Fatalf("stage mismatched craft readiness: %v", err) + } + if _, err = readinessTx.Exec(ctx, `SET CONSTRAINTS ALL IMMEDIATE`); err == nil { + _ = readinessTx.Rollback(ctx) + t.Fatal("deferred guard accepted positive craft chance with zero readiness") + } + _ = readinessTx.Rollback(ctx) + chanceTx, err := pool.Begin(ctx) + if err != nil { + t.Fatalf("begin craft chance guard probe: %v", err) + } + if _, err = chanceTx.Exec(ctx, `UPDATE unique_star_gifts SET craft_chance_permille=0 WHERE id=$1`, upgraded.Unique.ID); err != nil { + _ = chanceTx.Rollback(ctx) + t.Fatalf("stage mismatched craft chance: %v", err) + } + if _, err = chanceTx.Exec(ctx, `SET CONSTRAINTS ALL IMMEDIATE`); err == nil { + _ = chanceTx.Rollback(ctx) + t.Fatal("deferred guard accepted positive readiness with zero craft chance") + } + _ = chanceTx.Rollback(ctx) + terminalTx, err := pool.Begin(ctx) + if err != nil { + t.Fatalf("begin atomic craft terminal guard probe: %v", err) + } + if _, err = terminalTx.Exec(ctx, `UPDATE unique_star_gifts SET craft_chance_permille=0 WHERE id=$1`, upgraded.Unique.ID); err != nil { + _ = terminalTx.Rollback(ctx) + t.Fatalf("stage terminal craft chance: %v", err) + } + if _, err = terminalTx.Exec(ctx, `UPDATE peer_star_gifts SET can_craft_at=0 WHERE id=$1`, upgraded.Saved.ID); err != nil { + _ = terminalTx.Rollback(ctx) + t.Fatalf("stage terminal craft readiness: %v", err) + } + if _, err = terminalTx.Exec(ctx, `SET CONSTRAINTS ALL IMMEDIATE`); err != nil { + _ = terminalTx.Rollback(ctx) + t.Fatalf("deferred guard rejected atomic craft terminal state: %v", err) + } + if err = terminalTx.Rollback(ctx); err != nil { + t.Fatalf("rollback atomic craft terminal guard probe: %v", err) + } upgradeAction := upgraded.Send.RecipientMessage.Media.ServiceAction.StarGiftUnique senderUpgradeAction := upgraded.Send.SenderMessage.Media.ServiceAction.StarGiftUnique ownerSourceEdit := upgradedSourceEditForUser(upgraded, owner.ID) - if upgradeAction == nil || upgradeAction.SavedID != int64(purchased.Saved.MsgID) || - senderUpgradeAction == nil || senderUpgradeAction.SavedID != 0 || + if upgradeAction == nil || upgradeAction.SavedID != 0 || upgradeAction.Peer.Type != "" || upgradeAction.Peer.ID != 0 || + upgradeAction.CanCraftAt != now+2 || senderUpgradeAction == nil || senderUpgradeAction.SavedID != 0 || + senderUpgradeAction.CanCraftAt != now+2 || ownerSourceEdit.Message.Media == nil || ownerSourceEdit.Message.Media.ServiceAction == nil || ownerSourceEdit.Message.Media.ServiceAction.StarGift == nil || ownerSourceEdit.Message.Media.ServiceAction.StarGift.UpgradeMsgID != upgraded.Saved.UpgradeMsgID || @@ -307,6 +354,37 @@ WHERE b.owner_user_id=$1 AND b.box_id=$2`, owner.ID, upgraded.Send.RecipientMess if err != nil || transferred.Unique.Owner != ownerPeer || transferred.Saved.TransferStars != 25 || transferred.Balance.Balance != 9975 { t.Fatalf("paid transfer = %+v err %v", transferred, err) } + var retiredSourceMediaJSON string + var retiredSourcePTS int + if err := pool.QueryRow(ctx, `SELECT media::text,pts FROM message_boxes +WHERE owner_user_id=$1 AND box_id=$2 AND NOT deleted`, resaleBuyer.ID, resold.Saved.MsgID). + Scan(&retiredSourceMediaJSON, &retiredSourcePTS); err != nil { + t.Fatalf("load retired transfer source projection: %v", err) + } + retiredSourceMedia, err := decodeMessageMedia(retiredSourceMediaJSON) + if err != nil || retiredSourceMedia == nil || retiredSourceMedia.ServiceAction == nil || + retiredSourceMedia.ServiceAction.StarGiftUnique == nil { + t.Fatalf("decode retired transfer source projection: media=%+v err=%v", retiredSourceMedia, err) + } + retiredSourceAction := retiredSourceMedia.ServiceAction.StarGiftUnique + if retiredSourceAction.Gift.Owner != ownerPeer || retiredSourceAction.Gift.CraftChancePermille != 0 || + !retiredSourceAction.Transferred || retiredSourceAction.Saved || retiredSourceAction.CanCraftAt != 0 || + retiredSourceAction.CanExportAt != 0 || retiredSourceAction.TransferStars != 0 || + retiredSourceAction.CanTransferAt != 0 || retiredSourceAction.CanResellAt != 0 || + retiredSourceAction.DropOriginalDetailsStars != 0 || retiredSourceAction.ResaleAmount != nil { + t.Fatalf("retired transfer source remained actionable: %+v", retiredSourceAction) + } + var retiredEventCount, retiredOutboxCount int + if err := pool.QueryRow(ctx, `SELECT COUNT(*) FROM user_update_events +WHERE user_id=$1 AND pts=$2 AND event_type='edit_message' AND message_box_id=$3`, resaleBuyer.ID, retiredSourcePTS, resold.Saved.MsgID). + Scan(&retiredEventCount); err != nil || retiredEventCount != 1 { + t.Fatalf("retired transfer source event count=%d err=%v", retiredEventCount, err) + } + if err := pool.QueryRow(ctx, `SELECT COUNT(*) FROM dispatch_outbox +WHERE target_user_id=$1 AND pts=$2 AND event_type='edit_message'`, resaleBuyer.ID, retiredSourcePTS). + Scan(&retiredOutboxCount); err != nil || retiredOutboxCount != 1 { + t.Fatalf("retired transfer source outbox count=%d err=%v", retiredOutboxCount, err) + } clearedUser, found, err := users.ByID(ctx, resaleBuyer.ID) if err != nil || !found || !clearedUser.EmojiStatus().Empty() { t.Fatalf("transferred collectible status was not cleared: user=%+v found=%v err=%v", clearedUser, found, err) @@ -381,13 +459,13 @@ WHERE target_user_id=$1 AND pts=$2 AND event_type='user_emoji_status'`, resaleBu if _, err := gifts.ResolveSavedIDs(ctx, ownerPeer, []domain.SavedStarGiftRef{ {Owner: ownerPeer, MsgID: secondUpgrade.Saved.UpgradeMsgID}, {Owner: ownerPeer, Slug: secondUpgrade.Unique.Slug}, - }); !errors.Is(err, domain.ErrStarGiftNotFound) { - t.Fatalf("upgrade message id lookup err = %v, want ErrStarGiftNotFound", err) + }); !errors.Is(err, domain.ErrStarGiftCollectibleInvalid) { + t.Fatalf("duplicate upgrade output/slug identities err = %v", err) } if saved, found, err := gifts.GetByRef(ctx, domain.SavedStarGiftRef{ Owner: ownerPeer, MsgID: secondUpgrade.Saved.UpgradeMsgID, - }); err != nil || found { - t.Fatalf("upgrade message id resolved a gift: saved=%+v found=%v err=%v", saved, found, err) + }); err != nil || !found || saved.ID != secondUpgrade.Saved.ID { + t.Fatalf("upgrade output message id failed to resolve: saved=%+v found=%v err=%v", saved, found, err) } if _, err := gifts.ResolveSavedIDs(ctx, ownerPeer, []domain.SavedStarGiftRef{ {Owner: ownerPeer, MsgID: secondUpgrade.Saved.MsgID}, @@ -406,6 +484,15 @@ WHERE target_user_id=$1 AND pts=$2 AND event_type='user_emoji_status'`, resaleBu if err != nil || !crafted.Success || crafted.Chance != 1000 || crafted.Gift == nil || !crafted.Gift.Crafted || crafted.Send.RecipientMessage.ID <= 0 { t.Fatalf("craft result = %+v err %v", crafted, err) } + craftOutputAction := crafted.Send.SenderMessage.Media.ServiceAction.StarGiftUnique + if craftOutputAction == nil || craftOutputAction.Peer.Type != "" || craftOutputAction.Peer.ID != 0 || craftOutputAction.SavedID != 0 || !craftOutputAction.Craft { + t.Fatalf("craft output action leaked channel identity: %+v", craftOutputAction) + } + if byOutput, found, err := gifts.GetByRef(ctx, domain.SavedStarGiftRef{ + Owner: ownerPeer, MsgID: crafted.Send.SenderMessage.ID, + }); err != nil || !found || byOutput.ID != transferred.Saved.ID || byOutput.UniqueGiftID != crafted.Gift.ID { + t.Fatalf("craft output message ref = %+v found %v err %v", byOutput, found, err) + } craftedInputEdit := craftedSourceEditForUserAndGift(crafted, owner.ID, transferred.Unique.ID) craftedInputAction := starGiftUniqueActionFromEdit(craftedInputEdit) burnedInputEdit := craftedSourceEditForUserAndGift(crafted, owner.ID, secondUpgrade.Unique.ID) @@ -446,6 +533,40 @@ WHERE b.owner_user_id=$1 AND b.box_id=$2`, owner.ID, edit.Message.ID).Scan(&shar craftedSourceEditForUserAndGift(craftedReplay, owner.ID, secondUpgrade.Unique.ID).Event.Pts != burnedInputEdit.Event.Pts { t.Fatalf("craft success replay = %+v err %v", craftedReplay, err) } + craftOutputRef := domain.SavedStarGiftRef{Owner: ownerPeer, MsgID: crafted.Send.SenderMessage.ID} + if changed, err := gifts.SetUnsaved(ctx, craftOutputRef, true); err != nil || !changed { + t.Fatalf("hide crafted output before replay: changed=%v err=%v", changed, err) + } + hiddenReplay, err := lifecycle.CraftStarGift(ctx, craftReq) + if err != nil || !hiddenReplay.Duplicate || hiddenReplay.Send.SenderMessage.ID != crafted.Send.SenderMessage.ID { + t.Fatalf("craft replay after hide = %+v err %v", hiddenReplay, err) + } + if changed, err := gifts.SetUnsaved(ctx, craftOutputRef, false); err != nil || !changed { + t.Fatalf("restore crafted output before listing replay: changed=%v err=%v", changed, err) + } + listedCraftOutput, err := lifecycle.SetStarGiftListing(ctx, domain.StarGiftListingRequest{ActorUserID: owner.ID, + Ref: craftOutputRef, Amount: &domain.StarGiftAmount{Currency: domain.StarGiftCurrencyStars, Amount: 125}, Date: now + 148, + }) + if err != nil || listedCraftOutput.ResellAmount == nil || listedCraftOutput.ResellAmount.Amount != 125 { + t.Fatalf("list crafted output before replay = %+v err %v", listedCraftOutput, err) + } + listedReplay, err := lifecycle.CraftStarGift(ctx, craftReq) + if err != nil || !listedReplay.Duplicate || listedReplay.Gift == nil || listedReplay.Gift.ResellAmount != nil || + listedReplay.Send.SenderMessage.ID != crafted.Send.SenderMessage.ID { + t.Fatalf("craft replay after listing did not use frozen output = %+v err %v", listedReplay, err) + } + if _, err := lifecycle.SetStarGiftListing(ctx, domain.StarGiftListingRequest{ActorUserID: owner.ID, + Ref: craftOutputRef, Date: now + 149, + }); err != nil { + t.Fatalf("remove crafted output listing: %v", err) + } + var outputReceiptMedia string + var outputReceiptFingerprint []byte + if err := pool.QueryRow(ctx, `SELECT output_media::text,output_fingerprint FROM star_gift_craft_commands +WHERE user_id=$1 AND command_key=$2`, owner.ID, craftReq.CommandKey).Scan(&outputReceiptMedia, &outputReceiptFingerprint); err != nil || + outputReceiptMedia == "" || len(outputReceiptFingerprint) != 32 { + t.Fatalf("craft immutable output receipt: media=%q fingerprint=%d err=%v", outputReceiptMedia, len(outputReceiptFingerprint), err) + } var craftListings, resaleAvailability int if err := pool.QueryRow(ctx, `SELECT COUNT(*) FROM star_gift_listings WHERE unique_gift_id=ANY($1::bigint[])`, []int64{transferred.Unique.ID, secondUpgrade.Unique.ID}).Scan(&craftListings); err != nil || craftListings != 0 { @@ -487,7 +608,7 @@ WHERE b.owner_user_id=$1 AND b.box_id=$2`, owner.ID, edit.Message.ID).Scan(&shar WithStarGiftMarketPolicy(domain.StarGiftMarketPolicy{StarsProceedsPermille: 900, TONProceedsPermille: 900}), WithStarGiftCraftDraw(func(upper int) (int, error) { return upper - 1, nil })) failureReq := domain.StarGiftCraftRequest{UserID: owner.ID, - Refs: []domain.SavedStarGiftRef{{Owner: ownerPeer, MsgID: thirdUpgrade.Saved.MsgID}}, + Refs: []domain.SavedStarGiftRef{{Owner: ownerPeer, MsgID: thirdUpgrade.Saved.UpgradeMsgID}}, CommandKey: "craft-fail-" + suffix, Date: now + 150, } failedCraft, err := failingLifecycle.CraftStarGift(ctx, failureReq) @@ -526,6 +647,11 @@ can_resell_at,drop_original_details_stars,can_craft_at FROM peer_star_gifts WHER craftedSourceEditForUserAndGift(failedReplay, owner.ID, thirdUpgrade.Unique.ID).Event.Pts != failedInputEdit.Event.Pts { t.Fatalf("craft failure replay = %+v err %v", failedReplay, err) } + wrongAliasReplay := failureReq + wrongAliasReplay.Refs = []domain.SavedStarGiftRef{{Owner: ownerPeer, MsgID: secondUpgrade.Saved.UpgradeMsgID}} + if _, err := failingLifecycle.CraftStarGift(ctx, wrongAliasReplay); !errors.Is(err, domain.ErrStarGiftCraftUnavailable) { + t.Fatalf("craft replay accepted another aggregate alias: %v", err) + } invalidRetry := failureReq invalidRetry.CommandKey = "craft-fail-new-command-" + suffix if _, err := failingLifecycle.CraftStarGift(ctx, invalidRetry); !errors.Is(err, domain.ErrStarGiftCraftUnavailable) { @@ -629,10 +755,14 @@ func TestStarGiftChannelLifecycleAtomicPostgres(t *testing.T) { } if _, err := gifts.PublishCollectibleRevision(ctx, domain.StarGiftCollectibleWrite{ GiftID: entry.Gift.ID, UpgradeStars: 100, SupplyTotal: 5, SlugPrefix: "channel-life-" + suffix, - Models: []domain.StarGiftCollectibleAttribute{{Kind: domain.StarGiftCollectibleModel, Name: "Channel Model", RarityKind: domain.StarGiftRarityPermille, RarityPermille: 1000, - Document: collectibleTestDocumentPtr(baseDocumentID+1, "channel-model.tgs"), Blob: collectibleTestBlobPtr(baseDocumentID+1, "channel-model"), Animation: collectibleTestAnimationPtr("channel-model.tgs")}}, + Models: []domain.StarGiftCollectibleAttribute{ + {Kind: domain.StarGiftCollectibleModel, Name: "Channel Model", RarityKind: domain.StarGiftRarityPermille, RarityPermille: 1000, + Document: collectibleTestDocumentPtr(baseDocumentID+1, "channel-model.tgs"), Blob: collectibleTestBlobPtr(baseDocumentID+1, "channel-model"), Animation: collectibleTestAnimationPtr("channel-model.tgs")}, + {Kind: domain.StarGiftCollectibleModel, Name: "Channel Crafted", RarityKind: domain.StarGiftRarityLegendary, Crafted: true, + Document: collectibleTestDocumentPtr(baseDocumentID+2, "channel-crafted.tgs"), Blob: collectibleTestBlobPtr(baseDocumentID+2, "channel-crafted"), Animation: collectibleTestAnimationPtr("channel-crafted.tgs")}, + }, Patterns: []domain.StarGiftCollectibleAttribute{{Kind: domain.StarGiftCollectiblePattern, Name: "Channel Pattern", RarityKind: domain.StarGiftRarityPermille, RarityPermille: 1000, - Document: collectibleTestPatternDocumentPtr(baseDocumentID+2, "channel-pattern.tgs"), Blob: collectibleTestBlobPtr(baseDocumentID+2, "channel-pattern"), Animation: collectibleTestAnimationPtr("channel-pattern.tgs")}}, + Document: collectibleTestPatternDocumentPtr(baseDocumentID+3, "channel-pattern.tgs"), Blob: collectibleTestBlobPtr(baseDocumentID+3, "channel-pattern"), Animation: collectibleTestAnimationPtr("channel-pattern.tgs")}}, Backdrops: []domain.StarGiftCollectibleAttribute{{Kind: domain.StarGiftCollectibleBackdrop, Name: "Channel Backdrop", BackdropID: 88, CenterColor: 0x112233, EdgeColor: 0x223344, PatternColor: 0x334455, TextColor: 0xffffff, RarityKind: domain.StarGiftRarityPermille, RarityPermille: 1000}}, @@ -761,7 +891,9 @@ WHERE channel_id=$1 AND message::text LIKE '%prepaid_upgrade%'`, created.Channel } action := upgraded.Send.RecipientMessage.Media.ServiceAction.StarGiftUnique if action == nil || action.FromUserID != domain.OfficialSystemUserID || action.Peer != channelPeer || - action.SavedID != prepaidPurchase.Saved.SavedID || !action.Upgrade || !action.PrepaidUpgrade || action.TransferStars != 25 { + action.SavedID != prepaidPurchase.Saved.SavedID || !action.Upgrade || !action.PrepaidUpgrade || action.TransferStars != 25 || + action.CanCraftAt != 0 || action.Gift.CraftChancePermille != 500 || + upgraded.Saved.CanCraftAt != now+5 || upgraded.Unique.CraftChancePermille != 500 { t.Fatalf("channel upgrade service action = %+v", action) } var ptsAfterUpgrade int @@ -805,7 +937,9 @@ WHERE channel_id=$1 AND message::text LIKE '%star_gift_unique%'`, created.Channe } resold, err := lifecycle.PurchaseResaleStarGift(ctx, resaleReq) if err != nil || resold.Unique.Owner != targetChannelPeer || resold.Saved.Owner != targetChannelPeer || - resold.Saved.SavedID != upgraded.Saved.ID || resold.Balance.Balance != 999000 { + resold.Saved.SavedID != upgraded.Saved.ID || resold.Balance.Balance != 999000 || + resold.Saved.CanCraftAt != upgraded.Saved.CanCraftAt || + resold.Unique.CraftChancePermille != upgraded.Unique.CraftChancePermille { t.Fatalf("channel-to-channel local TON resale = %+v err %v", resold, err) } var channelTON, channelTONTxns, targetResaleLogs, commission int64 @@ -837,6 +971,36 @@ WHERE channel_id=$1 AND message::text LIKE '%star_gift_unique%'`, created.Channe if err := pool.QueryRow(ctx, `SELECT balance_nanoton FROM channel_ton_balances WHERE channel_id=$1`, created.Channel.ID).Scan(&channelTON); err != nil || channelTON != 900 { t.Fatalf("channel TON proceeds after replay = %d err %v", channelTON, err) } + toUser, err := lifecycle.TransferStarGift(ctx, domain.StarGiftTransferRequest{ + ActorUserID: actor.ID, + Ref: domain.SavedStarGiftRef{ + Owner: targetChannelPeer, SavedID: resold.Saved.SavedID, + }, + To: domain.Peer{Type: domain.PeerTypeUser, ID: actor.ID}, ChargeStars: resold.Saved.TransferStars, + CommandKey: "channel-craft-entitlement-to-user-" + suffix, Date: now + 8, + }) + if err != nil || toUser.Saved.Owner.Type != domain.PeerTypeUser || toUser.Saved.Owner.ID != actor.ID || + toUser.Saved.CanCraftAt != upgraded.Saved.CanCraftAt || + toUser.Unique.CraftChancePermille != upgraded.Unique.CraftChancePermille { + t.Fatalf("channel-to-user Craft entitlement transfer = %+v err %v", toUser, err) + } + toUserAction := toUser.Send.SenderMessage.Media.ServiceAction.StarGiftUnique + if toUserAction == nil || toUserAction.CanCraftAt != upgraded.Saved.CanCraftAt { + t.Fatalf("channel-to-user action did not restore Craft readiness: %+v", toUserAction) + } + backToChannel, err := lifecycle.TransferStarGift(ctx, domain.StarGiftTransferRequest{ + ActorUserID: actor.ID, + Ref: domain.SavedStarGiftRef{ + Owner: toUser.Saved.Owner, MsgID: toUser.Saved.MsgID, + }, + To: channelPeer, ChargeStars: toUser.Saved.TransferStars, + CommandKey: "user-craft-entitlement-to-channel-" + suffix, Date: now + 9, + }) + if err != nil || backToChannel.Saved.Owner != channelPeer || + backToChannel.Saved.CanCraftAt != upgraded.Saved.CanCraftAt || + backToChannel.Unique.CraftChancePermille != upgraded.Unique.CraftChancePermille { + t.Fatalf("user-to-channel Craft entitlement transfer = %+v err %v", backToChannel, err) + } var remainsBefore int if err := pool.QueryRow(ctx, `SELECT availability_remains FROM star_gift_catalog WHERE gift_id=$1`, entry.Gift.ID).Scan(&remainsBefore); err != nil { @@ -890,6 +1054,119 @@ WHERE channel_id=$1 AND message::text LIKE '%auction_acquired%'`, created.Channe } } +func TestStarGiftCraftFailureConsumesThreeInputsPostgres(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + suffix := randomSuffix(t) + now := int(time.Now().Unix()) + users := NewUserStore(pool) + buyer := createTestUser(t, ctx, users, "+1883"+suffix+"01", "CraftBuyer", "") + owner := createTestUser(t, ctx, users, "+1883"+suffix+"02", "CraftOwner", "") + ownerPeer := domain.Peer{Type: domain.PeerTypeUser, ID: owner.ID} + stars := NewStarsStore(pool) + for _, userID := range []int64{buyer.ID, owner.ID} { + if _, _, err := stars.EnsureGrant(ctx, userID, 10000, now); err != nil { + t.Fatalf("grant craft stars to %d: %v", userID, err) + } + } + + gifts := NewStarGiftStore(pool) + baseDocumentID := time.Now().UnixNano() & 0x7ffffffffffff000 + entry, err := gifts.CreateCatalogRevision(ctx, domain.StarGiftCatalogWrite{ + Title: "Three Input Craft " + suffix, Stars: 50, ConvertStars: 20, Enabled: true, + Document: collectibleTestDocument(baseDocumentID, "three-input.tgs"), + Blob: collectibleTestBlob(baseDocumentID, "three-input"), Animation: collectibleTestAnimation("three-input.tgs"), + Actor: "integration", CommandID: "three-input-catalog-" + suffix, + }) + if err != nil { + t.Fatalf("create three-input catalog: %v", err) + } + if _, err := gifts.PublishCollectibleRevision(ctx, domain.StarGiftCollectibleWrite{ + GiftID: entry.Gift.ID, UpgradeStars: 100, SupplyTotal: 10, SlugPrefix: "three-" + suffix, + Models: []domain.StarGiftCollectibleAttribute{ + {Kind: domain.StarGiftCollectibleModel, Name: "Base", RarityKind: domain.StarGiftRarityPermille, RarityPermille: 1000, + Document: collectibleTestDocumentPtr(baseDocumentID+1, "base.tgs"), Blob: collectibleTestBlobPtr(baseDocumentID+1, "base"), Animation: collectibleTestAnimationPtr("base.tgs")}, + {Kind: domain.StarGiftCollectibleModel, Name: "Crafted", RarityKind: domain.StarGiftRarityLegendary, Crafted: true, + Document: collectibleTestDocumentPtr(baseDocumentID+2, "crafted.tgs"), Blob: collectibleTestBlobPtr(baseDocumentID+2, "crafted"), Animation: collectibleTestAnimationPtr("crafted.tgs")}, + }, + Patterns: []domain.StarGiftCollectibleAttribute{{Kind: domain.StarGiftCollectiblePattern, Name: "Pattern", + RarityKind: domain.StarGiftRarityPermille, RarityPermille: 1000, + Document: collectibleTestPatternDocumentPtr(baseDocumentID+3, "pattern.tgs"), Blob: collectibleTestBlobPtr(baseDocumentID+3, "pattern"), Animation: collectibleTestAnimationPtr("pattern.tgs")}}, + Backdrops: []domain.StarGiftCollectibleAttribute{{Kind: domain.StarGiftCollectibleBackdrop, Name: "Backdrop", BackdropID: 88, + CenterColor: 0x112233, EdgeColor: 0x223344, PatternColor: 0x334455, TextColor: 0xffffff, + RarityKind: domain.StarGiftRarityPermille, RarityPermille: 1000}}, + Actor: "integration", CommandID: "three-input-pool-" + suffix, + }); err != nil { + t.Fatalf("publish three-input collectible: %v", err) + } + + messages := NewMessageStore(pool) + lifecycle := NewStarGiftLifecycleStore(pool, messages, 1_000_000, + WithStarGiftCraftDraw(func(upper int) (int, error) { return upper - 1, nil })) + upgrades := NewStarGiftUpgradeStore(pool, messages, WithStarGiftLifecyclePolicy(domain.StarGiftLifecyclePolicy{ + TransferStars: 25, DropOriginalDetailsStars: 25, OfferMinStars: 1, CraftChancePermille: 250, + })) + refs := make([]domain.SavedStarGiftRef, 0, 3) + uniqueIDs := make([]int64, 0, 3) + for i := 0; i < 3; i++ { + purchaseReq := issueLifecyclePurchaseForm(t, ctx, lifecycle, domain.StarGiftPurchaseRequest{ + BuyerUserID: buyer.ID, To: ownerPeer, GiftID: entry.Gift.ID, IncludeUpgrade: true, + CommandKey: fmt.Sprintf("three-input-purchase-%s-%d", suffix, i), Date: now + i, + }) + purchased, err := lifecycle.PurchaseStarGift(ctx, purchaseReq) + if err != nil { + t.Fatalf("purchase three-input gift %d: %v", i, err) + } + upgraded, err := upgrades.UpgradeStarGift(ctx, domain.StarGiftUpgradeRequest{UserID: owner.ID, + Ref: domain.SavedStarGiftRef{Owner: ownerPeer, MsgID: purchased.Saved.MsgID}, RequirePrepaid: true, + CommandKey: fmt.Sprintf("three-input-upgrade-%s-%d", suffix, i), Date: now + 10 + i, + }) + if err != nil { + t.Fatalf("upgrade three-input gift %d: %v", i, err) + } + refs = append(refs, domain.SavedStarGiftRef{Owner: ownerPeer, MsgID: upgraded.Saved.UpgradeMsgID}) + uniqueIDs = append(uniqueIDs, upgraded.Unique.ID) + } + req := domain.StarGiftCraftRequest{UserID: owner.ID, Refs: refs, + CommandKey: "three-input-craft-fail-" + suffix, Date: now + 20} + failed, err := lifecycle.CraftStarGift(ctx, req) + if err != nil || failed.Success || failed.Chance != 750 || failed.Gift != nil { + t.Fatalf("three-input craft failure = %+v err=%v", failed, err) + } + for _, uniqueID := range uniqueIDs { + edit := craftedSourceEditForUserAndGift(failed, owner.ID, uniqueID) + action := starGiftUniqueActionFromEdit(edit) + if edit.Event.Pts <= 0 || action == nil || !action.Gift.Burned || action.Gift.CraftChancePermille != 0 || + action.Saved || action.CanCraftAt != 0 { + t.Fatalf("three-input terminal edit for %d = %+v", uniqueID, edit) + } + var burned bool + var status string + if err := pool.QueryRow(ctx, `SELECT u.burned,p.lifecycle_status +FROM unique_star_gifts u JOIN peer_star_gifts p ON p.unique_gift_id=u.id WHERE u.id=$1`, uniqueID). + Scan(&burned, &status); err != nil || !burned || status != "burned" { + t.Fatalf("three-input terminal aggregate %d burned=%v status=%q err=%v", uniqueID, burned, status, err) + } + } + var sourcePTS []int32 + var outputMedia, outputFingerprint []byte + if err := pool.QueryRow(ctx, `SELECT source_edit_pts,output_media,output_fingerprint +FROM star_gift_craft_commands WHERE user_id=$1 AND command_key=$2`, owner.ID, req.CommandKey). + Scan(&sourcePTS, &outputMedia, &outputFingerprint); err != nil || len(sourcePTS) != 3 || + len(outputMedia) != 0 || len(outputFingerprint) != 0 { + t.Fatalf("three-input failure receipt pts=%v media=%d fingerprint=%d err=%v", sourcePTS, len(outputMedia), len(outputFingerprint), err) + } + replay, err := lifecycle.CraftStarGift(ctx, req) + if err != nil || !replay.Duplicate || replay.Success || replay.Chance != 750 { + t.Fatalf("three-input failure replay = %+v err=%v", replay, err) + } + for i, uniqueID := range uniqueIDs { + if edit := craftedSourceEditForUserAndGift(replay, owner.ID, uniqueID); edit.Event.Pts != int(sourcePTS[i]) { + t.Fatalf("three-input replay pts for %d = %d want %d", uniqueID, edit.Event.Pts, sourcePTS[i]) + } + } +} + func issueLifecyclePurchaseForm(t *testing.T, ctx context.Context, lifecycle *StarGiftLifecycleStore, req domain.StarGiftPurchaseRequest) domain.StarGiftPurchaseRequest { t.Helper() diff --git a/internal/store/postgres/star_gift_lifecycle_migration_integration_test.go b/internal/store/postgres/star_gift_lifecycle_migration_integration_test.go index a66c4fb5..f908b6d3 100644 --- a/internal/store/postgres/star_gift_lifecycle_migration_integration_test.go +++ b/internal/store/postgres/star_gift_lifecycle_migration_integration_test.go @@ -14,7 +14,7 @@ func TestStarGiftLifecycleMigrationsApply(t *testing.T) { if err != nil { t.Fatalf("migrate star gift lifecycle schema: %v", err) } - if status.Dirty || status.Empty || status.Version != 125 { - t.Fatalf("migration status = %+v, want clean version 125", status) + if status.Dirty || status.Empty || status.Version != 129 { + t.Fatalf("migration status = %+v, want clean version 129", status) } } diff --git a/internal/store/postgres/star_gift_lifecycle_projection.go b/internal/store/postgres/star_gift_lifecycle_projection.go new file mode 100644 index 00000000..86923ca5 --- /dev/null +++ b/internal/store/postgres/star_gift_lifecycle_projection.go @@ -0,0 +1,196 @@ +package postgres + +import ( + "context" + "errors" + "fmt" + "sort" + + "github.com/jackc/pgx/v5" + + "telesrv/internal/domain" + "telesrv/internal/store/postgres/sqlcgen" +) + +// retireUserStarGiftMessagesTx closes every user-scoped unique-gift action +// emitted for the source ownership epoch. Ownership moves and terminal export +// must not leave an older chat card with Craft/transfer/resale capabilities. +// The aggregate mutation and all message edits share one transaction and each +// visible box receives its own durable pts/event/outbox entry. +func (s *StarGiftLifecycleStore) retireUserStarGiftMessagesTx( + ctx context.Context, + tx pgx.Tx, + source domain.SavedStarGift, + current domain.UniqueStarGift, + date int, +) ([]domain.EditedMessageForUser, error) { + if s == nil || s.messages == nil || source.Owner.Type != domain.PeerTypeUser || source.Owner.ID <= 0 || + source.ID <= 0 || source.UniqueGiftID <= 0 || current.ID != source.UniqueGiftID || date <= 0 { + return nil, domain.ErrStarGiftTransferUnavailable + } + + messageIDs := map[int]struct{}{} + if source.MsgID > 0 { + messageIDs[source.MsgID] = struct{}{} + } + if source.UpgradeMsgID > 0 { + messageIDs[source.UpgradeMsgID] = struct{}{} + } + rows, err := tx.Query(ctx, ` +SELECT msg_id FROM star_gift_user_message_refs +WHERE owner_user_id=$1 AND saved_gift_id=$2 +ORDER BY msg_id`, source.Owner.ID, source.ID) + if err != nil { + return nil, fmt.Errorf("list star gift message projections: %w", err) + } + for rows.Next() { + var msgID int + if err := rows.Scan(&msgID); err != nil { + rows.Close() + return nil, fmt.Errorf("scan star gift message projection: %w", err) + } + if msgID > 0 { + messageIDs[msgID] = struct{}{} + } + } + if err := rows.Err(); err != nil { + rows.Close() + return nil, fmt.Errorf("iterate star gift message projections: %w", err) + } + rows.Close() + + ids := make([]int, 0, len(messageIDs)) + for msgID := range messageIDs { + ids = append(ids, msgID) + } + sort.Ints(ids) + + q := sqlcgen.New(tx) + edits := make([]domain.EditedMessageForUser, 0, len(ids)*2) + seenPrivateMessages := make(map[string]struct{}, len(ids)) + for _, msgID := range ids { + var peerType string + var peerID int64 + err := tx.QueryRow(ctx, ` +SELECT peer_type,peer_id FROM message_boxes +WHERE owner_user_id=$1 AND box_id=$2 AND NOT deleted +FOR UPDATE`, source.Owner.ID, msgID).Scan(&peerType, &peerID) + if errors.Is(err, pgx.ErrNoRows) { + continue + } + if err != nil { + return nil, fmt.Errorf("lock star gift message projection: %w", err) + } + if peerType != string(domain.PeerTypeUser) || peerID <= 0 { + return nil, fmt.Errorf("star gift message projection %d is not private", msgID) + } + target, err := q.GetMessageBoxForEdit(ctx, sqlcgen.GetMessageBoxForEditParams{ + OwnerUserID: source.Owner.ID, BoxID: int32(msgID), PeerType: peerType, PeerID: peerID, + }) + if err != nil { + return nil, fmt.Errorf("load star gift message projection: %w", err) + } + logicalKey := fmt.Sprintf("%d:%d", target.MessageSenderID, target.PrivateMessageID) + if _, duplicate := seenPrivateMessages[logicalKey]; duplicate { + continue + } + seenPrivateMessages[logicalKey] = struct{}{} + + boxes, err := q.ListVisibleMessageBoxesByPrivateMessage(ctx, sqlcgen.ListVisibleMessageBoxesByPrivateMessageParams{ + OwnerUserIds: privateMessageOwnerIDs(source.Owner.ID, peerID), + MessageSenderID: target.MessageSenderID, PrivateMessageID: target.PrivateMessageID, + }) + if err != nil { + return nil, fmt.Errorf("list visible star gift message projections: %w", err) + } + var privateMediaJSON []byte + matched := false + for _, box := range boxes { + media, err := decodeMessageMedia(box.MediaJson) + if err != nil { + return nil, fmt.Errorf("decode star gift message projection: %w", err) + } + if media == nil || media.Kind != domain.MessageMediaKindService || media.ServiceAction == nil || + media.ServiceAction.Kind != domain.MessageServiceActionStarGiftUnique || media.ServiceAction.StarGiftUnique == nil || + media.ServiceAction.StarGiftUnique.Gift.ID != current.ID { + continue + } + matched = true + action := media.ServiceAction.StarGiftUnique + retiredGift := current + retiredGift.CraftChancePermille = 0 + retiredGift.ResellAmount = nil + action.Gift = retiredGift + action.Peer = domain.Peer{} + action.SavedID = 0 + action.Saved = false + if validLifecyclePeer(current.Owner) && current.Owner != source.Owner { + action.Transferred = true + } + action.CanExportAt = 0 + action.TransferStars = 0 + action.ResaleAmount = nil + action.CanTransferAt = 0 + action.CanResellAt = 0 + action.DropOriginalDetailsStars = 0 + action.CanCraftAt = 0 + + mediaJSON, err := encodeMessageMedia(media) + if err != nil { + return nil, fmt.Errorf("encode retired star gift projection: %w", err) + } + pts, err := s.messages.reservePts(ctx, tx, box.OwnerUserID) + if err != nil { + return nil, fmt.Errorf("allocate retired star gift pts: %w", err) + } + tag, err := tx.Exec(ctx, ` +UPDATE message_boxes SET media=$3,pts=$4 +WHERE owner_user_id=$1 AND box_id=$2 AND NOT deleted`, box.OwnerUserID, box.BoxID, mediaJSON, int32(pts)) + if err != nil { + return nil, fmt.Errorf("update retired star gift projection: %w", err) + } + if tag.RowsAffected() != 1 { + return nil, fmt.Errorf("update retired star gift projection lost row") + } + msg, err := messageFromVisibleBoxRow(box) + if err != nil { + return nil, err + } + msg.Media = media + msg.Pts = pts + if err := replaceMessageBoxMediaIndexTx(ctx, tx, msg.OwnerUserID, msg.Peer.ID, msg.ID, msg.Date, msg.Media, msg.Entities); err != nil { + return nil, err + } + event := domain.UpdateEvent{UserID: msg.OwnerUserID, Type: domain.UpdateEventEditMessage, + Pts: pts, PtsCount: 1, Date: date, Message: msg} + if err := appendUserUpdateEvent(ctx, tx, q, msg.OwnerUserID, event); err != nil { + return nil, fmt.Errorf("append retired star gift edit event: %w", err) + } + if err := enqueueDispatch(ctx, q, sqlcgen.EnqueueDispatchParams{ + TargetUserID: msg.OwnerUserID, Pts: int32(pts), EventType: string(domain.UpdateEventEditMessage), + ExcludeAuthKeyID: 0, ExcludeSessionID: 0, + }); err != nil { + return nil, fmt.Errorf("enqueue retired star gift edit: %w", err) + } + if box.OwnerUserID == box.MessageSenderID || len(privateMediaJSON) == 0 { + privateMediaJSON, err = encodeSharedPrivateStarGiftMedia(media) + if err != nil { + return nil, err + } + } + edits = append(edits, domain.EditedMessageForUser{UserID: msg.OwnerUserID, Message: msg, Event: event}) + } + if !matched { + continue + } + if len(privateMediaJSON) == 0 { + return nil, fmt.Errorf("retired star gift projection missing shared media") + } + if _, err := tx.Exec(ctx, ` +UPDATE private_messages SET media=$3 +WHERE sender_user_id=$1 AND id=$2`, target.MessageSenderID, target.PrivateMessageID, privateMediaJSON); err != nil { + return nil, fmt.Errorf("update retired star gift private media: %w", err) + } + } + return edits, nil +} diff --git a/internal/store/postgres/star_gift_lifecycle_test.go b/internal/store/postgres/star_gift_lifecycle_test.go index 04190537..7e844cc7 100644 --- a/internal/store/postgres/star_gift_lifecycle_test.go +++ b/internal/store/postgres/star_gift_lifecycle_test.go @@ -7,19 +7,38 @@ import ( ) func TestTransferUniqueActionSavedIDNamespace(t *testing.T) { - saved := domain.SavedStarGift{SavedID: 42} + saved := domain.SavedStarGift{SavedID: 42, CanCraftAt: 1_780_000_123} unique := domain.UniqueStarGift{ID: 7} user := domain.Peer{Type: domain.PeerTypeUser, ID: 100} channel := domain.Peer{Type: domain.PeerTypeChannel, ID: 200} - if action := transferUniqueAction(unique, 1, user, saved); action.SavedID != 0 { + if action := transferUniqueAction(unique, 1, user, saved); action.SavedID != 0 || action.CanCraftAt != saved.CanCraftAt { t.Fatalf("user transfer action leaked channel saved_id: %+v", action) } - if action := transferUniqueAction(unique, 1, channel, saved); action.SavedID != saved.SavedID { + if action := transferUniqueAction(unique, 1, channel, saved); action.SavedID != saved.SavedID || action.CanCraftAt != 0 { t.Fatalf("channel transfer action lost channel saved_id: %+v", action) } } +func TestStarGiftCraftReadyAt(t *testing.T) { + const date = 1_780_000_000 + if got := starGiftCraftReadyAt(date, 0); got != date { + t.Fatalf("zero-delay craft ready_at = %d, want %d", got, date) + } + if got := starGiftCraftReadyAt(date, 60); got != date+60 { + t.Fatalf("delayed craft ready_at = %d, want %d", got, date+60) + } + if got := starGiftCraftReadyAt(0, 0); got != 0 { + t.Fatalf("invalid-date craft ready_at = %d, want 0", got) + } + if got := starGiftCraftReadyAt(1<<31-10, 60); got != 1<<31-1 { + t.Fatalf("overflow craft ready_at = %d, want max int32", got) + } + if got := starGiftCraftReadyAt(1<<31+10, 0); got != 1<<31-1 { + t.Fatalf("oversized-date craft ready_at = %d, want max int32", got) + } +} + func TestEncodeSharedPrivateStarGiftMediaOmitsUserBoxLocalRefs(t *testing.T) { ordinary := &domain.MessageMedia{ Kind: domain.MessageMediaKindService, diff --git a/internal/store/postgres/star_gift_private_projection.go b/internal/store/postgres/star_gift_private_projection.go index 2fcfc665..8ed76d18 100644 --- a/internal/store/postgres/star_gift_private_projection.go +++ b/internal/store/postgres/star_gift_private_projection.go @@ -11,11 +11,10 @@ import ( // projectPrivateStarGiftSourceRef exposes a user-owned gift's stable source // message identity only in the gift owner's message-box projection. Telegram -// defines gift_msg_id as receiver-only, and TDesktop also treats user unique -// saved_id as an inputSavedStarGiftUser identity. A non-owner counterpart box -// id is therefore not a valid substitute: it could resolve to an unrelated -// gift owned by that viewer. The shared private_messages row omits the local -// reference for the same reason. +// defines gift_msg_id as receiver-only. A non-owner counterpart box id is not +// a valid substitute: it could resolve to an unrelated gift owned by that +// viewer. User unique actions do not use channel-only peer/saved_id fields; +// their owner-scoped message ids are registered separately at write time. func projectPrivateStarGiftSourceRef( _ context.Context, _ pgx.Tx, @@ -60,25 +59,6 @@ func projectPrivateStarGiftSourceRef( } else { recipientAction.GiftMsgID = sourceOwnerBoxID } - case privateStarGiftUniqueAction(shared) != nil: - sharedAction := privateStarGiftUniqueAction(shared) - senderAction := privateStarGiftUniqueAction(sender) - recipientAction := privateStarGiftUniqueAction(recipient) - if sharedAction.Peer.Type != domain.PeerTypeUser || sharedAction.Peer.ID != sourceOwnerUserID || - sharedAction.SavedID != int64(sourceOwnerBoxID) { - return privateSendMediaProjection{}, fmt.Errorf( - "project private unique star gift source: saved_id %d does not match owner box %d", - sharedAction.SavedID, sourceOwnerBoxID, - ) - } - sharedAction.SavedID = 0 - senderAction.SavedID = 0 - recipientAction.SavedID = 0 - if req.SenderUserID == sourceOwnerUserID { - senderAction.SavedID = int64(sourceOwnerBoxID) - } else { - recipientAction.SavedID = int64(sourceOwnerBoxID) - } default: return privateSendMediaProjection{}, fmt.Errorf("project private star gift source: unsupported media") } diff --git a/internal/store/postgres/star_gift_upgrade.go b/internal/store/postgres/star_gift_upgrade.go index 56c89d93..b786e289 100644 --- a/internal/store/postgres/star_gift_upgrade.go +++ b/internal/store/postgres/star_gift_upgrade.go @@ -122,9 +122,15 @@ WHERE collectible_revision_id=$1 AND crafted } craftChancePermille := 0 canCraftAt := 0 + // Keep the durable Craft entitlement attached to the collectible across + // user/channel ownership moves. The RPC projection suppresses the + // readiness marker for channel owners until channel Craft execution is + // implemented, without destroying the official gift property. if craftable { craftChancePermille = s.lifecycle.CraftChancePermille - canCraftAt = starGiftReadyAt(req.Date, s.lifecycle.CraftDelaySeconds) + if craftChancePermille > 0 { + canCraftAt = starGiftCraftReadyAt(req.Date, s.lifecycle.CraftDelaySeconds) + } } if revision.Issued >= revision.SupplyTotal { return domain.ErrStarGiftCollectibleSoldOut @@ -230,12 +236,6 @@ VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)`, req.UserID, commandKey, locked.ID, req.For } return nil }, - projectMedia: func(ctx context.Context, tx pgx.Tx, messageReq *domain.SendPrivateTextRequest) (privateSendMediaProjection, error) { - if result.Saved.Owner.Type != domain.PeerTypeUser { - return privateSendMediaProjection{Shared: messageReq.Media, Sender: messageReq.Media, Recipient: messageReq.Media}, nil - } - return projectPrivateStarGiftSourceRef(ctx, tx, messageReq, result.Saved.Owner.ID, result.Saved.MsgID) - }, after: func(ctx context.Context, tx pgx.Tx, sent domain.SendPrivateTextResult) error { ownerMessageID := sent.RecipientMessage.ID if saved.FromUserID == req.UserID { @@ -251,6 +251,12 @@ VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)`, req.UserID, commandKey, locked.ID, req.For if tag.RowsAffected() != 1 { return fmt.Errorf("save star gift upgrade message id lost aggregate row") } + if result.Saved.Owner.Type == domain.PeerTypeUser { + if err := registerUserStarGiftMessageRef(ctx, tx, result.Saved.Owner.ID, ownerMessageID, + result.Saved.ID, result.Unique.ID); err != nil { + return err + } + } result.Saved.UpgradeMsgID = ownerMessageID if result.Saved.Owner.Type == domain.PeerTypeUser { edits, err := s.markPrivateStarGiftSourceUpgradedTx(ctx, tx, req, result.Saved, sent) @@ -311,19 +317,27 @@ func starGiftUpgradeUniqueAction(saved domain.SavedStarGift, unique domain.Uniqu // peer plus action.peer=channel and action.saved_id. fromUserID = messageSenderID } + peer := saved.Owner savedID := saved.SavedID + canCraftAt := saved.CanCraftAt if saved.Owner.Type == domain.PeerTypeUser { - // For user-owned gifts messageActionStarGiftUnique.saved_id is the - // stable source gift message id. TDesktop uses this back-reference as - // inputSavedStarGiftUser.msg_id for crafting and later lifecycle RPCs. - savedID = int64(saved.MsgID) + // peer and saved_id share one TL flag and are defined for channel gifts. + // For user gifts both must be absent; official clients use the emitted + // service-message id (registered owner-locally by the send transaction). + peer = domain.Peer{} + savedID = 0 + } else { + // The current Craft state machine is user-owned only. Android treats a + // positive can_craft_at as the channel Craft entry marker, so do not + // advertise a write path that the server cannot execute yet. + canCraftAt = 0 } return &domain.MessageStarGiftUniqueAction{ - Gift: unique, FromUserID: fromUserID, Peer: saved.Owner, SavedID: savedID, + Gift: unique, FromUserID: fromUserID, Peer: peer, SavedID: savedID, Upgrade: true, Saved: !saved.Unsaved, PrepaidUpgrade: req.RequirePrepaid, CanExportAt: saved.CanExportAt, TransferStars: saved.TransferStars, CanTransferAt: saved.CanTransferAt, CanResellAt: saved.CanResellAt, - DropOriginalDetailsStars: saved.DropOriginalDetailsStars, CanCraftAt: saved.CanCraftAt, + DropOriginalDetailsStars: saved.DropOriginalDetailsStars, CanCraftAt: canCraftAt, } } @@ -469,6 +483,27 @@ func starGiftReadyAt(date, delaySeconds int) int { return date + delaySeconds } +// starGiftCraftReadyAt differs intentionally from the other lifecycle delay +// fields. Official Android clients use a positive can_craft_at both as the +// capability marker and as the readiness boundary, so an immediately +// craftable gift must carry its upgrade date instead of omitting the field. +func starGiftCraftReadyAt(date, delaySeconds int) int { + if date <= 0 || delaySeconds < 0 { + return 0 + } + const maxProtocolDate = int(1<<31 - 1) + if date >= maxProtocolDate { + return maxProtocolDate + } + if delaySeconds == 0 { + return date + } + if delaySeconds > maxProtocolDate-date { + return maxProtocolDate + } + return date + delaySeconds +} + func lockSavedStarGiftForUpgrade(ctx context.Context, tx pgx.Tx, ref domain.SavedStarGiftRef) (domain.SavedStarGift, error) { where, args := savedStarGiftRefWhere(ref) return lockSavedStarGiftWhere(ctx, tx, where, args...) diff --git a/internal/store/postgres/star_gift_user_message_ref.go b/internal/store/postgres/star_gift_user_message_ref.go new file mode 100644 index 00000000..b1a4da81 --- /dev/null +++ b/internal/store/postgres/star_gift_user_message_ref.go @@ -0,0 +1,59 @@ +package postgres + +import ( + "context" + "fmt" + + "github.com/jackc/pgx/v5" +) + +// registerUserStarGiftMessageRef records an owner-scoped service-message alias +// for a user-owned gift. Official clients may continue from a freshly emitted +// messageActionStarGiftUnique and pass that message id to a lifecycle RPC, +// while payments.getSavedStarGifts may still expose the original received gift +// message as the aggregate's primary msg_id. +func registerUserStarGiftMessageRef( + ctx context.Context, + tx pgx.Tx, + ownerUserID int64, + msgID int, + savedGiftID int64, + uniqueGiftID int64, +) error { + if ownerUserID <= 0 || msgID <= 0 || savedGiftID <= 0 || uniqueGiftID <= 0 { + return fmt.Errorf("register user star gift message ref: invalid identity") + } + tag, err := tx.Exec(ctx, ` +INSERT INTO star_gift_user_message_refs(owner_user_id,msg_id,saved_gift_id) +SELECT $1,$2,p.id +FROM peer_star_gifts p +WHERE p.id=$3 AND p.owner_peer_type='user' AND p.owner_peer_id=$1 + AND p.unique_gift_id=$4 AND p.lifecycle_status='active' +ON CONFLICT(owner_user_id,msg_id) DO UPDATE +SET saved_gift_id=EXCLUDED.saved_gift_id +WHERE star_gift_user_message_refs.saved_gift_id=EXCLUDED.saved_gift_id`, ownerUserID, msgID, savedGiftID, uniqueGiftID) + if err != nil { + return fmt.Errorf("register user star gift message ref: %w", err) + } + if tag.RowsAffected() != 1 { + return fmt.Errorf("register user star gift message ref: identity collision") + } + return nil +} + +func userStarGiftMessageRefMatches( + ctx context.Context, + db interface { + QueryRow(context.Context, string, ...any) pgx.Row + }, + ownerUserID int64, + msgID int, + savedGiftID int64, +) (bool, error) { + var matches bool + err := db.QueryRow(ctx, `SELECT EXISTS ( +SELECT 1 FROM star_gift_user_message_refs +WHERE owner_user_id=$1 AND msg_id=$2 AND saved_gift_id=$3 +)`, ownerUserID, msgID, savedGiftID).Scan(&matches) + return matches, err +}