fix: sync StarGift lifecycle hardening

This commit is contained in:
A 2026-07-21 15:47:55 +08:00
parent bfdfe825f6
commit d69a34a4a8
30 changed files with 1883 additions and 132 deletions

View file

@ -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;

View file

@ -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';

View file

@ -0,0 +1 @@
-- Validation changes no data and the constraint belongs to migration 0126.

View file

@ -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;

View file

@ -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;
$$;

View file

@ -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;
$$;

View file

@ -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;

View file

@ -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)
);

View file

@ -55,9 +55,9 @@ const tdesktopClient = "tdesktop"
// //
// WebK directly calls Array.some on fragment_prefixes while rendering user profiles, // 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. // 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 提供客户端启动配置与国家区号目录。 // Service 提供客户端启动配置与国家区号目录。
// //

View file

@ -48,6 +48,7 @@ func TestAppConfigPremiumKeys(t *testing.T) {
"reactions_user_max_default": 1, "reactions_user_max_default": 1,
"reactions_user_max_premium": 3, "reactions_user_max_premium": 3,
"boosts_channel_level_max": 100, "boosts_channel_level_max": 100,
"stargifts_pinned_to_top_limit": 6,
"about_length_limit_default": 70, "about_length_limit_default": 70,
"about_length_limit_premium": 140, "about_length_limit_premium": 140,
"dialogs_pinned_limit_default": 5, "dialogs_pinned_limit_default": 5,

View file

@ -879,6 +879,9 @@ const (
MaxStarGiftCollectionTitleRunes = 12 MaxStarGiftCollectionTitleRunes = 12
MaxStarGiftCollectionsPerPeer = 100 MaxStarGiftCollectionsPerPeer = 100
MaxStarGiftCollectionItems = 1000 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)。 // Star gift 哨兵错误(rpc 层 errors.Is 映射为 tgerr)。

View file

@ -269,7 +269,10 @@ func tgMessageActionStarGiftUnique(action *domain.MessageStarGiftUniqueAction) t
if action.DropOriginalDetailsStars > 0 { if action.DropOriginalDetailsStars > 0 {
out.SetDropOriginalDetailsStars(action.DropOriginalDetailsStars) 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) out.SetCanCraftAt(action.CanCraftAt)
} }
if action.FromUserID != 0 { if action.FromUserID != 0 {

View file

@ -1007,9 +1007,11 @@ func starGiftLifecycleErr(err error) error {
return tgerr.New(400, "STARGIFT_OWNER_INVALID") return tgerr.New(400, "STARGIFT_OWNER_INVALID")
case errors.Is(err, domain.ErrStarGiftWithdrawalUnavailable): case errors.Is(err, domain.ErrStarGiftWithdrawalUnavailable):
return tgerr.New(400, "STARGIFT_WITHDRAWAL_UNAVAILABLE") 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), 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.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.ErrStarGiftUnavailable), errors.Is(err, domain.ErrStarGiftInvalid),
errors.Is(err, domain.ErrStarGiftCollectibleUnavailable): errors.Is(err, domain.ErrStarGiftCollectibleUnavailable):
return starGiftInvalidErr() return starGiftInvalidErr()

View file

@ -1071,6 +1071,27 @@ func tgSavedStarGifts(gifts []domain.SavedStarGift, catalog map[int64]domain.Sta
item.SetPrepaidUpgradeHash(g.PrepaidUpgradeHash) 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 { if g.PinnedOrder > 0 {
item.PinnedToTop = true item.PinnedToTop = true
} }

View file

@ -118,7 +118,7 @@ func (s *craftStarGiftRPCService) GetSaved(_ context.Context, ref domain.SavedSt
} }
continue continue
} }
if saved.MsgID == ref.MsgID { if saved.MsgID == ref.MsgID || saved.UpgradeMsgID == ref.MsgID {
return saved, true, nil 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) 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}, &tg.InputSavedStarGiftUser{MsgID: 116},
}}) }})
if !tgerr.Is(err, "STARGIFT_INVALID") || service.craftCall != 0 { if err != nil || updates == nil || service.craftCall != 1 || service.craftReq.CommandKey != "rpc:50" ||
t.Fatalf("upgrade message id accepted as gift identity: err=%v craft calls=%d", err, service.craftCall) 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) { func TestMessageStarGiftProjectionSeparatesPaidPriceFromPrepaidAmount(t *testing.T) {
ordinary, ok := tgMessageActionStarGift(&domain.MessageStarGiftAction{ ordinary, ok := tgMessageActionStarGift(&domain.MessageStarGiftAction{
GiftID: 8001, Stars: 50, ConvertStars: 25, CanUpgrade: true, UpgradePriceStars: 75, GiftID: 8001, Stars: 50, ConvertStars: 25, CanUpgrade: true, UpgradePriceStars: 75,

View file

@ -530,6 +530,15 @@ func (s *StarGiftStore) SetUnsaved(_ context.Context, ref domain.SavedStarGiftRe
for i := range s.gifts { for i := range s.gifts {
if s.savedStarGiftMatchesRef(s.gifts[i], ref) && s.gifts[i].LifecycleStatus.Live() { if s.savedStarGiftMatchesRef(s.gifts[i], ref) && s.gifts[i].LifecycleStatus.Live() {
s.gifts[i].Unsaved = unsaved 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 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 { func (s *StarGiftStore) SetPinned(_ context.Context, owner domain.Peer, savedGiftIDs []int64) error {
s.mu.Lock() s.mu.Lock()
defer s.mu.Unlock() defer s.mu.Unlock()
if len(savedGiftIDs) > domain.MaxPinnedStarGifts {
return domain.ErrStarGiftCollectibleInvalid
}
ids, err := s.validCollectionGiftIDsLocked(owner, savedGiftIDs) ids, err := s.validCollectionGiftIDsLocked(owner, savedGiftIDs)
if err != nil { if err != nil {
return err return err
} }
if len(ids) != len(savedGiftIDs) {
return domain.ErrStarGiftCollectibleInvalid
}
order := make(map[int64]int, len(ids)) order := make(map[int64]int, len(ids))
for i, id := range ids { for i, id := range ids {
order[id] = i + 1 order[id] = i + 1
@ -712,6 +727,9 @@ func (s *StarGiftStore) SetPinned(_ context.Context, owner domain.Peer, savedGif
for i := range s.gifts { for i := range s.gifts {
if s.gifts[i].Owner == owner { if s.gifts[i].Owner == owner {
s.gifts[i].PinnedOrder = order[s.gifts[i].ID] s.gifts[i].PinnedOrder = order[s.gifts[i].ID]
if s.gifts[i].PinnedOrder > 0 {
s.gifts[i].Unsaved = false
}
} }
} }
return nil return nil

View file

@ -47,6 +47,24 @@ func TestStarGiftProfilePinOrderAndPagination(t *testing.T) {
if !slices.Equal(got, want) { if !slices.Equal(got, want) {
t.Fatalf("paged order = %v, want %v", 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 { if err := store.SetPinned(ctx, owner, nil); err != nil {
t.Fatalf("clear pinned: %v", err) t.Fatalf("clear pinned: %v", err)

View file

@ -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' 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[]))` AND (p.saved_id::bigint=ANY($3::bigint[]) OR u.slug=ANY($4::text[]))`
if owner.Type == domain.PeerTypeUser { 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 FROM peer_star_gifts p
LEFT JOIN unique_star_gifts u ON u.id=p.unique_gift_id 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' 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[]))` OR u.slug=ANY($4::text[]))`
} }
rows, err := s.db.Query(ctx, query, string(owner.Type), owner.ID, values, slugs) 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() { if !ref.Valid() {
return false, domain.ErrStarGiftNotFound return false, domain.ErrStarGiftNotFound
} }
where, args := savedStarGiftRefWhere(ref) changed := false
args = append(args, unsaved) err := withTx(ctx, s.db, "set star gift unsaved", func(tx pgx.Tx) error {
tag, err := s.db.Exec(ctx, ` if _, err := tx.Exec(ctx, `SELECT pg_advisory_xact_lock(hashtextextended($1, 0))`, starGiftCollectionLockKey(ref.Owner)); err != nil {
UPDATE peer_star_gifts SET unsaved = $4 return err
WHERE `+where+` AND lifecycle_status='active'`, args...) }
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 { if err != nil {
return false, fmt.Errorf("set star gift unsaved: %w", err) 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) { 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 return "owner_peer_type = $1 AND owner_peer_id = $2 AND saved_id = $3", args
default: default:
args = append(args, ref.MsgID) 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
} }
} }

View file

@ -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 { 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 { 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 { if _, err := tx.Exec(ctx, `SELECT pg_advisory_xact_lock(hashtextextended($1, 0))`, starGiftCollectionLockKey(owner)); err != nil {
return err return err
@ -698,11 +701,14 @@ func (s *StarGiftStore) SetPinned(ctx context.Context, owner domain.Peer, savedG
if err != nil { if err != nil {
return err 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 { 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 return err
} }
for order, id := range ids { 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 return err
} }
} }

View file

@ -116,13 +116,28 @@ func TestStarGiftCollectibleUpgradeAggregatePostgres(t *testing.T) {
t.Fatalf("owner upgrade service message = %+v", ownerMessage) t.Fatalf("owner upgrade service message = %+v", ownerMessage)
} }
uniqueAction := ownerMessage.Media.ServiceAction.StarGiftUnique uniqueAction := ownerMessage.Media.ServiceAction.StarGiftUnique
if uniqueAction.SavedID != int64(saved.MsgID) { if uniqueAction.SavedID != 0 || uniqueAction.Peer.Type != "" || uniqueAction.Peer.ID != 0 {
t.Fatalf("unique action saved_id = %d, want stable source msg id %d", uniqueAction.SavedID, saved.MsgID) t.Fatalf("user unique action leaked channel peer/saved_id: %+v", uniqueAction)
} }
senderUniqueAction := upgraded.Send.SenderMessage.Media.ServiceAction.StarGiftUnique senderUniqueAction := upgraded.Send.SenderMessage.Media.ServiceAction.StarGiftUnique
if senderUniqueAction == nil || senderUniqueAction.SavedID != 0 { if senderUniqueAction == nil || senderUniqueAction.SavedID != 0 {
t.Fatalf("sender unique action leaked owner-only saved_id: %+v", senderUniqueAction) 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) ownerSourceEdit := upgradedSourceEditForUser(upgraded, owner.ID)
if ownerSourceEdit.Event.Pts <= ownerMessage.Pts || ownerSourceEdit.Message.Media == nil || if ownerSourceEdit.Event.Pts <= ownerMessage.Pts || ownerSourceEdit.Message.Media == nil ||
ownerSourceEdit.Message.Media.ServiceAction == nil || ownerSourceEdit.Message.Media.ServiceAction.StarGift == nil || ownerSourceEdit.Message.Media.ServiceAction == nil || ownerSourceEdit.Message.Media.ServiceAction.StarGift == nil ||

View file

@ -11,6 +11,7 @@ import (
"github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5"
"telesrv/internal/domain" "telesrv/internal/domain"
"telesrv/internal/store"
) )
const ( const (
@ -18,6 +19,37 @@ const (
maxStarGiftAuctionAcquired = 1000 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) { func defaultStarGiftCraftDraw(upper int) (int, error) {
if upper <= 0 { if upper <= 0 {
return 0, domain.ErrStarGiftCraftUnavailable return 0, domain.ErrStarGiftCraftUnavailable
@ -158,7 +190,8 @@ func (s *StarGiftLifecycleStore) ListCraftStarGifts(ctx context.Context, userID,
} }
args := []any{userID, giftID} args := []any{userID, giftID}
where := `p.owner_peer_type='user' AND p.owner_peer_id=$1 AND p.gift_id=$2 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 NOT u.burned AND u.owner_address='' AND u.craft_chance_permille>0
AND EXISTS (SELECT 1 FROM star_gift_collectible_models m AND EXISTS (SELECT 1 FROM star_gift_collectible_models m
WHERE m.collectible_revision_id=u.collectible_revision_id AND m.crafted)` 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 // A committed failed craft has already moved every input out of the active
// lifecycle. Consult the immutable receipt before active-gift resolution so // lifecycle. Consult the immutable receipt before active-gift resolution so
// an exact transport retry can still replay the same terminal result. // 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 { if err != nil || !replay.Success {
return replay, err 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) savedIDs, err := NewStarGiftStore(s.db).ResolveSavedIDs(ctx, owner, req.Refs)
if err != nil { if err != nil {
@ -248,7 +281,7 @@ func (s *StarGiftLifecycleStore) CraftStarGift(ctx context.Context, req domain.S
return domain.StarGiftCraftResult{}, domain.ErrStarGiftCraftUnavailable return domain.StarGiftCraftResult{}, domain.ErrStarGiftCraftUnavailable
} }
var result domain.StarGiftCraftResult var result domain.StarGiftCraftResult
var resultUniqueID int64 var output starGiftCraftOutputIntent
err = withTx(ctx, s.db, "craft star gift", func(tx pgx.Tx) error { 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)) 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 { if err != nil {
@ -269,7 +302,7 @@ func (s *StarGiftLifecycleStore) CraftStarGift(ctx context.Context, req domain.S
chance := 0 chance := 0
for i := range req.Refs { for i := range req.Refs {
saved, err := lockSavedStarGiftByID(ctx, tx, savedIDs[i]) 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 return domain.ErrStarGiftCraftUnavailable
} }
unique, found, err := NewStarGiftStore(tx).UniqueByID(ctx, saved.UniqueGiftID) 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 result.SourceEdits = sourceEdits
var resultID any var resultID any
var outputMediaJSON any
var outputFingerprint any
if result.Success { if result.Success {
resultID = firstUniqueID 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, _, 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, success,result_unique_gift_id,chance_permille,created_at,source_edit_pts,output_media,output_fingerprint)
strings.TrimSpace(req.CommandKey), uniqueIDs, giftID, result.Success, resultID, chance, req.Date, sourceEditPTS) 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 return err
}) })
if err != nil { if err != nil {
if isUniqueViolation(err) { 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 replay, replayErr
} }
} }
return domain.StarGiftCraftResult{}, err return domain.StarGiftCraftResult{}, err
} }
if result.Success { if result.Success {
gift, found, err := NewStarGiftStore(s.db).UniqueByID(ctx, resultUniqueID) return s.deliverCraftSuccess(ctx, req, result, output)
if err != nil || !found {
return domain.StarGiftCraftResult{}, domain.ErrStarGiftCraftUnavailable
}
result.Gift = &gift
}
if result.Success {
return s.deliverCraftSuccess(ctx, req, result)
} }
return result, nil return result, nil
} }
func (s *StarGiftLifecycleStore) deliverCraftSuccess(ctx context.Context, req domain.StarGiftCraftRequest, result domain.StarGiftCraftResult) (domain.StarGiftCraftResult, error) { 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 { 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 return domain.StarGiftCraftResult{}, domain.ErrStarGiftCraftUnavailable
} }
saved, found, err := savedStarGiftByUniqueID(ctx, s.db, result.Gift.ID) messageReq := starGiftCraftOutputRequest(req, output)
if err != nil || !found || saved.Owner != (domain.Peer{Type: domain.PeerTypeUser, ID: req.UserID}) { hooks := privateSendTxHooks{after: func(ctx context.Context, tx pgx.Tx, sent domain.SendPrivateTextResult) error {
return domain.StarGiftCraftResult{}, domain.ErrStarGiftCraftUnavailable return registerUserStarGiftMessageRef(ctx, tx, req.UserID, sent.SenderMessage.ID, output.SavedGiftID, result.Gift.ID)
} }}
sent, err := s.messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{SenderUserID: req.UserID, sent, err := s.messages.sendPrivateTextWithHooks(ctx, messageReq, hooks)
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}}}})
if err != nil { if err != nil {
return domain.StarGiftCraftResult{}, err 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.Send = sent
result.Duplicate = result.Duplicate || sent.Duplicate result.Duplicate = result.Duplicate || sent.Duplicate
return result, nil 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 success bool
var resultID *int64 var resultID *int64
var chance int var chance int
var inputUniqueIDs []int64 var inputUniqueIDs []int64
var sourceEditPTS []int32 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`, 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) { if errors.Is(err, pgx.ErrNoRows) {
return domain.StarGiftCraftResult{}, false, nil return domain.StarGiftCraftResult{}, starGiftCraftOutputIntent{}, false, nil
} }
if err != 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) { 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)) savedIDs := make([]int64, 0, len(inputUniqueIDs))
owner := domain.Peer{Type: domain.PeerTypeUser, ID: req.UserID} owner := domain.Peer{Type: domain.PeerTypeUser, ID: req.UserID}
for i, uniqueID := range inputUniqueIDs { for i, uniqueID := range inputUniqueIDs {
saved, found, err := savedStarGiftByUniqueID(ctx, s.db, uniqueID) saved, found, err := savedStarGiftByUniqueID(ctx, s.db, uniqueID)
if err != nil || !found || saved.Owner != owner || saved.UniqueGiftID != 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] ref := req.Refs[i]
if ref.Owner != owner || ref.Slug == "" && ref.MsgID != saved.MsgID { if ref.Owner != owner {
return domain.StarGiftCraftResult{}, false, domain.ErrStarGiftCraftUnavailable return domain.StarGiftCraftResult{}, starGiftCraftOutputIntent{}, false, domain.ErrStarGiftCraftUnavailable
} }
if ref.Slug != "" { if ref.Slug != "" {
unique, found, err := NewStarGiftStore(s.db).UniqueByID(ctx, uniqueID) unique, found, err := NewStarGiftStore(s.db).UniqueByID(ctx, uniqueID)
if err != nil || !found || !strings.EqualFold(ref.Slug, unique.Slug) { 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) savedIDs = append(savedIDs, saved.ID)
} }
sourceEdits, err := s.loadCraftInputMessageReplays(ctx, req, savedIDs, sourceEditPTS) sourceEdits, err := s.loadCraftInputMessageReplays(ctx, req, savedIDs, sourceEditPTS)
if err != nil { 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} result := domain.StarGiftCraftResult{Success: success, Chance: chance, SourceEdits: sourceEdits, Duplicate: true}
if resultID != nil { if !success {
gift, found, err := NewStarGiftStore(s.db).UniqueByID(ctx, *resultID) if resultID != nil || len(outputMediaJSON) != 0 || len(outputFingerprint) != 0 {
if err != nil || !found { return domain.StarGiftCraftResult{}, starGiftCraftOutputIntent{}, false, domain.ErrStarGiftCraftUnavailable
return domain.StarGiftCraftResult{}, 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) { func chooseCraftedModel(ctx context.Context, tx pgx.Tx, revisionID int64) (int64, error) {

View file

@ -142,6 +142,26 @@ func TestStarGiftStorePostgres(t *testing.T) {
if !slices.Equal(gotMsgIDs, wantMsgIDs) { if !slices.Equal(gotMsgIDs, wantMsgIDs) {
t.Fatalf("pinned paged msg ids = %v, want %v", 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 { if err := st.SetPinned(ctx, ownerPeer, nil); err != nil {
t.Fatalf("clear pinned profile order: %v", err) t.Fatalf("clear pinned profile order: %v", err)
} }

View file

@ -389,6 +389,7 @@ func (s *StarGiftLifecycleStore) TransferStarGift(ctx context.Context, req domai
}}, }},
} }
var result domain.StarGiftTransferResult var result domain.StarGiftTransferResult
var sourceSaved domain.SavedStarGift
hooks := privateSendTxHooks{ hooks := privateSendTxHooks{
before: func(ctx context.Context, tx pgx.Tx, send *domain.SendPrivateTextRequest) error { before: func(ctx context.Context, tx pgx.Tx, send *domain.SendPrivateTextRequest) error {
saved, unique, err := lockTransferableStarGift(ctx, tx, req.ActorUserID, req.Ref, req.Date) 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 { if err := removeSavedGiftFromCollections(ctx, tx, saved.Owner, saved.ID); err != nil {
return err return err
} }
sourceSaved = saved
unique.Owner = req.To unique.Owner = req.To
saved.Owner = req.To saved.Owner = req.To
result.Saved, result.Unique, result.Balance = saved, unique, balance 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 { can_transfer_at=0 WHERE id=$1`, result.Saved.ID, req.To.ID, req.ActorUserID, msgID, req.Date); err != nil {
return err 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, 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) 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, 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.MsgID, result.Saved.SavedID, result.Saved.UpgradeMsgID, result.Saved.Date = msgID, 0, msgID, req.Date
result.Saved.FromUserID = req.ActorUserID 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 return nil
}, },
} }
@ -502,6 +511,7 @@ func (s *StarGiftLifecycleStore) PurchaseResaleStarGift(ctx context.Context, req
} }
var result domain.StarGiftTransferResult var result domain.StarGiftTransferResult
var commissionAmount int64 var commissionAmount int64
var sourceSaved domain.SavedStarGift
hooks := privateSendTxHooks{ hooks := privateSendTxHooks{
before: func(ctx context.Context, tx pgx.Tx, send *domain.SendPrivateTextRequest) error { before: func(ctx context.Context, tx pgx.Tx, send *domain.SendPrivateTextRequest) error {
var listingCurrency, sellerType string var listingCurrency, sellerType string
@ -557,12 +567,15 @@ func (s *StarGiftLifecycleStore) PurchaseResaleStarGift(ctx context.Context, req
gift.ResellAmount = nil gift.ResellAmount = nil
gift.LastSaleDate = req.Date gift.LastSaleDate = req.Date
gift.LastSaleAmount = &domain.StarGiftAmount{Currency: req.Amount.Currency, Amount: req.Amount.Amount} gift.LastSaleAmount = &domain.StarGiftAmount{Currency: req.Amount.Currency, Amount: req.Amount.Amount}
sourceSaved = saved
saved.Owner = req.To saved.Owner = req.To
if req.To.Type == domain.PeerTypeChannel { if req.To.Type == domain.PeerTypeChannel {
saved.MsgID, saved.SavedID = 0, saved.ID saved.MsgID, saved.SavedID = 0, saved.ID
} }
result.Saved, result.Unique, result.Balance = saved, gift, balance result.Saved, result.Unique, result.Balance = saved, gift, balance
send.Media.ServiceAction.StarGiftUnique = transferUniqueAction(gift, messageSenderID, req.To, saved) send.Media.ServiceAction.StarGiftUnique = transferUniqueAction(gift, messageSenderID, req.To, saved)
resaleAmount := req.Amount
send.Media.ServiceAction.StarGiftUnique.ResaleAmount = &resaleAmount
return nil return nil
}, },
after: func(ctx context.Context, tx pgx.Tx, sent domain.SendPrivateTextResult) error { 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 { WHERE id=$1`, result.Saved.ID, string(req.To.Type), req.To.ID, messageSenderID, msgID, savedID, req.Date); err != nil {
return err 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, 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) 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, 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.MsgID, result.Saved.SavedID, result.Saved.UpgradeMsgID, result.Saved.Date = msgID, savedID, msgID, req.Date
result.Saved.FromUserID = messageSenderID 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) return updateStarGiftResaleProjection(ctx, tx, result.Unique.GiftID)
}, },
} }
@ -803,7 +826,7 @@ func (s *StarGiftLifecycleStore) ResolveStarGiftOffer(ctx context.Context, req d
offer.Gift = gift offer.Gift = gift
actionKind := domain.MessageServiceActionStarGiftUnique actionKind := domain.MessageServiceActionStarGiftUnique
action := &domain.MessageServiceAction{Kind: actionKind, StarGiftUnique: &domain.MessageStarGiftUniqueAction{ 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, Transferred: true, FromOffer: true, Saved: true,
}} }}
if req.Decline { if req.Decline {
@ -816,6 +839,7 @@ func (s *StarGiftLifecycleStore) ResolveStarGiftOffer(ctx context.Context, req d
Media: &domain.MessageMedia{Kind: domain.MessageMediaKindService, ServiceAction: action}} Media: &domain.MessageMedia{Kind: domain.MessageMediaKindService, ServiceAction: action}}
var result domain.StarGiftOfferResult var result domain.StarGiftOfferResult
var commissionAmount int64 var commissionAmount int64
var sourceSaved domain.SavedStarGift
hooks := privateSendTxHooks{ hooks := privateSendTxHooks{
before: func(ctx context.Context, tx pgx.Tx, send *domain.SendPrivateTextRequest) error { 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, 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.LastSaleDate = req.Date
current.LastSaleAmount = &locked.Price current.LastSaleAmount = &locked.Price
locked.Status, locked.ResolvedAt, locked.Gift = "accepted", req.Date, current 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.Offer = locked
result.Unique = current result.Unique = current
result.Saved = saved 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 return nil
}, },
after: func(ctx context.Context, tx pgx.Tx, sent domain.SendPrivateTextResult) error { 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 { WHERE id=$1`, result.Saved.ID, result.Offer.BuyerUserID, req.OwnerUserID, msgID, req.Date); err != nil {
return err 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.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 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, 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 { req.Date, fmt.Sprintf("offer:%d", result.Offer.ID)); err != nil {
return err 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) 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 { if err := removeSavedGiftFromCollections(ctx, tx, saved.Owner, saved.ID); err != nil {
return err 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 { 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 return err
} }
@ -1100,6 +1137,11 @@ func (s *StarGiftLifecycleStore) transferStarGiftWithoutPrivateMessage(ctx conte
return err return err
} }
result.Saved, result.Unique, result.Balance = saved, unique, balance 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 nil
}) })
return result, err 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 { func transferUniqueAction(unique domain.UniqueStarGift, fromUserID int64, to domain.Peer, saved domain.SavedStarGift) *domain.MessageStarGiftUniqueAction {
peer := to
savedID := saved.SavedID savedID := saved.SavedID
canCraftAt := saved.CanCraftAt
if to.Type == domain.PeerTypeUser { if to.Type == domain.PeerTypeUser {
// For a user-owned transferred gift the action message itself becomes // peer and saved_id are a shared channel-only TL flag. A user-owned
// inputSavedStarGiftUser.msg_id. A channel saved_id belongs to a different // transferred gift is managed by this action message's id.
// identity namespace and must never leak into the recipient's user view. peer = domain.Peer{}
savedID = 0 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, SavedID: savedID, Transferred: true, Saved: true, CanExportAt: saved.CanExportAt,
TransferStars: saved.TransferStars, CanTransferAt: saved.CanTransferAt, CanResellAt: saved.CanResellAt, 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, 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)) requestHash := sha256.Sum256([]byte(providerRequestID))
giftAddress := fmt.Sprintf("telesrv-gift:%s:%x", unique.Slug, requestHash[:8]) 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, 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 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 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 { 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 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) return updateStarGiftResaleProjection(ctx, tx, unique.GiftID)
}) })
if err != nil { if err != nil {

View file

@ -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) t.Fatalf("upgrade prepaid gift: %v", err)
} }
if upgraded.Saved.TransferStars != 25 || upgraded.Saved.DropOriginalDetailsStars != 25 || if upgraded.Saved.TransferStars != 25 || upgraded.Saved.DropOriginalDetailsStars != 25 ||
upgraded.Saved.CanCraftAt != now+2 ||
upgraded.Unique.CraftChancePermille != 500 || !upgraded.Unique.KeepOriginalDetails { upgraded.Unique.CraftChancePermille != 500 || !upgraded.Unique.KeepOriginalDetails {
t.Fatalf("issued lifecycle snapshot = saved %+v unique %+v", upgraded.Saved, upgraded.Unique) 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 upgradeAction := upgraded.Send.RecipientMessage.Media.ServiceAction.StarGiftUnique
senderUpgradeAction := upgraded.Send.SenderMessage.Media.ServiceAction.StarGiftUnique senderUpgradeAction := upgraded.Send.SenderMessage.Media.ServiceAction.StarGiftUnique
ownerSourceEdit := upgradedSourceEditForUser(upgraded, owner.ID) ownerSourceEdit := upgradedSourceEditForUser(upgraded, owner.ID)
if upgradeAction == nil || upgradeAction.SavedID != int64(purchased.Saved.MsgID) || if upgradeAction == nil || upgradeAction.SavedID != 0 || upgradeAction.Peer.Type != "" || upgradeAction.Peer.ID != 0 ||
senderUpgradeAction == nil || senderUpgradeAction.SavedID != 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 == nil || ownerSourceEdit.Message.Media.ServiceAction == nil ||
ownerSourceEdit.Message.Media.ServiceAction.StarGift == nil || ownerSourceEdit.Message.Media.ServiceAction.StarGift == nil ||
ownerSourceEdit.Message.Media.ServiceAction.StarGift.UpgradeMsgID != upgraded.Saved.UpgradeMsgID || 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 { if err != nil || transferred.Unique.Owner != ownerPeer || transferred.Saved.TransferStars != 25 || transferred.Balance.Balance != 9975 {
t.Fatalf("paid transfer = %+v err %v", transferred, err) 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) clearedUser, found, err := users.ByID(ctx, resaleBuyer.ID)
if err != nil || !found || !clearedUser.EmojiStatus().Empty() { if err != nil || !found || !clearedUser.EmojiStatus().Empty() {
t.Fatalf("transferred collectible status was not cleared: user=%+v found=%v err=%v", clearedUser, found, err) 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{ if _, err := gifts.ResolveSavedIDs(ctx, ownerPeer, []domain.SavedStarGiftRef{
{Owner: ownerPeer, MsgID: secondUpgrade.Saved.UpgradeMsgID}, {Owner: ownerPeer, MsgID: secondUpgrade.Saved.UpgradeMsgID},
{Owner: ownerPeer, Slug: secondUpgrade.Unique.Slug}, {Owner: ownerPeer, Slug: secondUpgrade.Unique.Slug},
}); !errors.Is(err, domain.ErrStarGiftNotFound) { }); !errors.Is(err, domain.ErrStarGiftCollectibleInvalid) {
t.Fatalf("upgrade message id lookup err = %v, want ErrStarGiftNotFound", err) t.Fatalf("duplicate upgrade output/slug identities err = %v", err)
} }
if saved, found, err := gifts.GetByRef(ctx, domain.SavedStarGiftRef{ if saved, found, err := gifts.GetByRef(ctx, domain.SavedStarGiftRef{
Owner: ownerPeer, MsgID: secondUpgrade.Saved.UpgradeMsgID, Owner: ownerPeer, MsgID: secondUpgrade.Saved.UpgradeMsgID,
}); err != nil || found { }); err != nil || !found || saved.ID != secondUpgrade.Saved.ID {
t.Fatalf("upgrade message id resolved a gift: saved=%+v found=%v err=%v", saved, found, err) 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{ if _, err := gifts.ResolveSavedIDs(ctx, ownerPeer, []domain.SavedStarGiftRef{
{Owner: ownerPeer, MsgID: secondUpgrade.Saved.MsgID}, {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 { 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) 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) craftedInputEdit := craftedSourceEditForUserAndGift(crafted, owner.ID, transferred.Unique.ID)
craftedInputAction := starGiftUniqueActionFromEdit(craftedInputEdit) craftedInputAction := starGiftUniqueActionFromEdit(craftedInputEdit)
burnedInputEdit := craftedSourceEditForUserAndGift(crafted, owner.ID, secondUpgrade.Unique.ID) 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 { craftedSourceEditForUserAndGift(craftedReplay, owner.ID, secondUpgrade.Unique.ID).Event.Pts != burnedInputEdit.Event.Pts {
t.Fatalf("craft success replay = %+v err %v", craftedReplay, err) 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 var craftListings, resaleAvailability int
if err := pool.QueryRow(ctx, `SELECT COUNT(*) FROM star_gift_listings WHERE unique_gift_id=ANY($1::bigint[])`, 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 { []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}), WithStarGiftMarketPolicy(domain.StarGiftMarketPolicy{StarsProceedsPermille: 900, TONProceedsPermille: 900}),
WithStarGiftCraftDraw(func(upper int) (int, error) { return upper - 1, nil })) WithStarGiftCraftDraw(func(upper int) (int, error) { return upper - 1, nil }))
failureReq := domain.StarGiftCraftRequest{UserID: owner.ID, 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, CommandKey: "craft-fail-" + suffix, Date: now + 150,
} }
failedCraft, err := failingLifecycle.CraftStarGift(ctx, failureReq) 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 { craftedSourceEditForUserAndGift(failedReplay, owner.ID, thirdUpgrade.Unique.ID).Event.Pts != failedInputEdit.Event.Pts {
t.Fatalf("craft failure replay = %+v err %v", failedReplay, err) 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 := failureReq
invalidRetry.CommandKey = "craft-fail-new-command-" + suffix invalidRetry.CommandKey = "craft-fail-new-command-" + suffix
if _, err := failingLifecycle.CraftStarGift(ctx, invalidRetry); !errors.Is(err, domain.ErrStarGiftCraftUnavailable) { 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{ if _, err := gifts.PublishCollectibleRevision(ctx, domain.StarGiftCollectibleWrite{
GiftID: entry.Gift.ID, UpgradeStars: 100, SupplyTotal: 5, SlugPrefix: "channel-life-" + suffix, 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, Models: []domain.StarGiftCollectibleAttribute{
Document: collectibleTestDocumentPtr(baseDocumentID+1, "channel-model.tgs"), Blob: collectibleTestBlobPtr(baseDocumentID+1, "channel-model"), Animation: collectibleTestAnimationPtr("channel-model.tgs")}}, {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, 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, Backdrops: []domain.StarGiftCollectibleAttribute{{Kind: domain.StarGiftCollectibleBackdrop, Name: "Channel Backdrop", BackdropID: 88,
CenterColor: 0x112233, EdgeColor: 0x223344, PatternColor: 0x334455, TextColor: 0xffffff, CenterColor: 0x112233, EdgeColor: 0x223344, PatternColor: 0x334455, TextColor: 0xffffff,
RarityKind: domain.StarGiftRarityPermille, RarityPermille: 1000}}, 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 action := upgraded.Send.RecipientMessage.Media.ServiceAction.StarGiftUnique
if action == nil || action.FromUserID != domain.OfficialSystemUserID || action.Peer != channelPeer || 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) t.Fatalf("channel upgrade service action = %+v", action)
} }
var ptsAfterUpgrade int 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) resold, err := lifecycle.PurchaseResaleStarGift(ctx, resaleReq)
if err != nil || resold.Unique.Owner != targetChannelPeer || resold.Saved.Owner != targetChannelPeer || 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) t.Fatalf("channel-to-channel local TON resale = %+v err %v", resold, err)
} }
var channelTON, channelTONTxns, targetResaleLogs, commission int64 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 { 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) 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 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 { 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, func issueLifecyclePurchaseForm(t *testing.T, ctx context.Context, lifecycle *StarGiftLifecycleStore,
req domain.StarGiftPurchaseRequest) domain.StarGiftPurchaseRequest { req domain.StarGiftPurchaseRequest) domain.StarGiftPurchaseRequest {
t.Helper() t.Helper()

View file

@ -14,7 +14,7 @@ func TestStarGiftLifecycleMigrationsApply(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("migrate star gift lifecycle schema: %v", err) t.Fatalf("migrate star gift lifecycle schema: %v", err)
} }
if status.Dirty || status.Empty || status.Version != 125 { if status.Dirty || status.Empty || status.Version != 129 {
t.Fatalf("migration status = %+v, want clean version 125", status) t.Fatalf("migration status = %+v, want clean version 129", status)
} }
} }

View file

@ -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
}

View file

@ -7,19 +7,38 @@ import (
) )
func TestTransferUniqueActionSavedIDNamespace(t *testing.T) { 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} unique := domain.UniqueStarGift{ID: 7}
user := domain.Peer{Type: domain.PeerTypeUser, ID: 100} user := domain.Peer{Type: domain.PeerTypeUser, ID: 100}
channel := domain.Peer{Type: domain.PeerTypeChannel, ID: 200} 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) 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) 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) { func TestEncodeSharedPrivateStarGiftMediaOmitsUserBoxLocalRefs(t *testing.T) {
ordinary := &domain.MessageMedia{ ordinary := &domain.MessageMedia{
Kind: domain.MessageMediaKindService, Kind: domain.MessageMediaKindService,

View file

@ -11,11 +11,10 @@ import (
// projectPrivateStarGiftSourceRef exposes a user-owned gift's stable source // projectPrivateStarGiftSourceRef exposes a user-owned gift's stable source
// message identity only in the gift owner's message-box projection. Telegram // 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 // defines gift_msg_id as receiver-only. A non-owner counterpart box id is not
// saved_id as an inputSavedStarGiftUser identity. A non-owner counterpart box // a valid substitute: it could resolve to an unrelated gift owned by that
// id is therefore not a valid substitute: it could resolve to an unrelated // viewer. User unique actions do not use channel-only peer/saved_id fields;
// gift owned by that viewer. The shared private_messages row omits the local // their owner-scoped message ids are registered separately at write time.
// reference for the same reason.
func projectPrivateStarGiftSourceRef( func projectPrivateStarGiftSourceRef(
_ context.Context, _ context.Context,
_ pgx.Tx, _ pgx.Tx,
@ -60,25 +59,6 @@ func projectPrivateStarGiftSourceRef(
} else { } else {
recipientAction.GiftMsgID = sourceOwnerBoxID 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: default:
return privateSendMediaProjection{}, fmt.Errorf("project private star gift source: unsupported media") return privateSendMediaProjection{}, fmt.Errorf("project private star gift source: unsupported media")
} }

View file

@ -122,9 +122,15 @@ WHERE collectible_revision_id=$1 AND crafted
} }
craftChancePermille := 0 craftChancePermille := 0
canCraftAt := 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 { if craftable {
craftChancePermille = s.lifecycle.CraftChancePermille 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 { if revision.Issued >= revision.SupplyTotal {
return domain.ErrStarGiftCollectibleSoldOut 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 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 { after: func(ctx context.Context, tx pgx.Tx, sent domain.SendPrivateTextResult) error {
ownerMessageID := sent.RecipientMessage.ID ownerMessageID := sent.RecipientMessage.ID
if saved.FromUserID == req.UserID { 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 { if tag.RowsAffected() != 1 {
return fmt.Errorf("save star gift upgrade message id lost aggregate row") 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 result.Saved.UpgradeMsgID = ownerMessageID
if result.Saved.Owner.Type == domain.PeerTypeUser { if result.Saved.Owner.Type == domain.PeerTypeUser {
edits, err := s.markPrivateStarGiftSourceUpgradedTx(ctx, tx, req, result.Saved, sent) 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. // peer plus action.peer=channel and action.saved_id.
fromUserID = messageSenderID fromUserID = messageSenderID
} }
peer := saved.Owner
savedID := saved.SavedID savedID := saved.SavedID
canCraftAt := saved.CanCraftAt
if saved.Owner.Type == domain.PeerTypeUser { if saved.Owner.Type == domain.PeerTypeUser {
// For user-owned gifts messageActionStarGiftUnique.saved_id is the // peer and saved_id share one TL flag and are defined for channel gifts.
// stable source gift message id. TDesktop uses this back-reference as // For user gifts both must be absent; official clients use the emitted
// inputSavedStarGiftUser.msg_id for crafting and later lifecycle RPCs. // service-message id (registered owner-locally by the send transaction).
savedID = int64(saved.MsgID) 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{ 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, Upgrade: true, Saved: !saved.Unsaved, PrepaidUpgrade: req.RequirePrepaid,
CanExportAt: saved.CanExportAt, TransferStars: saved.TransferStars, CanExportAt: saved.CanExportAt, TransferStars: saved.TransferStars,
CanTransferAt: saved.CanTransferAt, CanResellAt: saved.CanResellAt, 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 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) { func lockSavedStarGiftForUpgrade(ctx context.Context, tx pgx.Tx, ref domain.SavedStarGiftRef) (domain.SavedStarGift, error) {
where, args := savedStarGiftRefWhere(ref) where, args := savedStarGiftRefWhere(ref)
return lockSavedStarGiftWhere(ctx, tx, where, args...) return lockSavedStarGiftWhere(ctx, tx, where, args...)

View file

@ -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
}